diff --git a/.gitignore b/.gitignore index 0e2ef4328..fff13b0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist pnpm-lock.yaml coverage .pnpm-store +.DS_Store +src/.DS_Store diff --git a/docs/agents.md b/docs/agents.md index 6b431ca9d..5b462f8f2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,6 +1,7 @@ # Agent Integration 🤖 CLAWDIS now ships with a single coding agent: Pi (the Tau CLI). Legacy Claude/Codex/Gemini/Opencode paths have been removed. +Pi is bundled as a dependency of `clawdis`, so a fresh `pnpm install` gives you the `pi`/`tau` binaries automatically. ## Pi / Tau @@ -10,10 +11,11 @@ The recommended (and only) agent for CLAWDIS. Built by Mario Zechner, forked wit { "reply": { "mode": "command", - "agent": { - "kind": "pi", - "format": "json" - }, + "agent": { + "kind": "pi", + "format": "json", + "model": "claude-opus-4-5" // default if omitted + }, "command": [ "node", "/path/to/pi-mono/packages/coding-agent/dist/cli.js", @@ -44,6 +46,8 @@ RPC mode is enforced by CLAWDIS (we rewrite `--mode` to `rpc` for Pi invocations - 📊 Token usage tracking - 🔄 Streaming responses +If the agent does not report a model, CLAWDIS assumes `claude-opus-4-5` with ~200k context tokens (pi-ai defaults) for usage summaries. + ## Session Management ### Per-Sender Sessions diff --git a/docs/configuration.md b/docs/configuration.md index 528d4794b..66bccedf0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,7 +43,9 @@ CLAWDIS uses a JSON configuration file at `~/.clawdis/clawdis.json`. "mode": "command", "agent": { "kind": "pi", - "format": "json" + "format": "json", + "model": "claude-opus-4-5", + "contextTokens": 200000 }, "cwd": "/Users/you/clawd", "command": [ @@ -99,6 +101,11 @@ Array of E.164 phone numbers allowed to trigger the AI. Use `["*"]` to allow eve | `timeoutSeconds` | number | Max time for agent to respond | | `heartbeatMinutes` | number | Interval for heartbeat pings | | `heartbeatBody` | string | Message sent on heartbeat | +| `agent.kind` | string | Only `"pi"` is supported | +| `agent.model` | string | Optional model name to annotate sessions (defaults to `claude-opus-4-5`) | +| `agent.contextTokens` | number | Optional context window size; used for session token % reporting (defaults to ~200,000 for Opus 4.5) | + +> Quick start: If you omit `inbound.reply`, CLAWDIS falls back to the bundled `@mariozechner/pi-coding-agent` with `--mode rpc`, per-sender sessions, and a 200k-token window. No extra install or config needed to get a reply. ### Template Variables diff --git a/docs/index.md b/docs/index.md index 7a3fd94a1..de3adf769 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ clawdis status - [Direct Agent CLI](./agent-send.md) — Use `warelay agent` without sending WhatsApp messages - [Group Chats](./groups.md) — Mention patterns and filtering - [Media Handling](./media.md) — Images, voice, documents +- [Session Management](./session.md) — How conversations are keyed and reset - [Security](./security.md) — Keeping your lobster safe - [Troubleshooting](./troubleshooting.md) — When the CLAWDIS misbehaves diff --git a/docs/session.md b/docs/session.md new file mode 100644 index 000000000..f92faa0b7 --- /dev/null +++ b/docs/session.md @@ -0,0 +1,55 @@ +# Session Management + +CLAWDIS keeps lightweight session state so your agent can remember context between messages. Sessions are stored in a small JSON file and expire automatically after idle time or when you reset them. + +## Where sessions live + +- Default path: `~/.clawdis/sessions.json` (legacy: `~/.warelay/sessions.json`). +- Override with `inbound.reply.session.store` in your config if you want a custom location. +- The file is a plain map of `sessionKey -> { sessionId, updatedAt, ... }`; it is safe to delete if you want a full reset. + +## How session keys are chosen + +- Direct chats: normalized E.164 sender number (e.g., `+15551234567`). +- Group chats: `group:` so group history stays isolated from DMs. +- Global mode: set `inbound.reply.session.scope = "global"` to force a single shared session for all chats. +- Unknown senders fall back to `unknown`. + +## When sessions reset + +- Idle timeout: `inbound.reply.session.idleMinutes` (default 60). If no messages arrive within this window, a new `sessionId` is created on the next message. +- Reset triggers: `inbound.reply.session.resetTriggers` (default `['/new']`). Sending exactly `/new` or `/new ` starts a fresh session and passes the remaining text to the agent. +- Manual nuke: delete the store file or remove specific keys with `jq`/your editor; a new file is created on the next message. + +## Configuration recap + +```json5 +// ~/.clawdis/clawdis.json +{ + inbound: { + reply: { + session: { + scope: "per-sender", // or "global" + resetTriggers: ["/new"], // additional triggers allowed + idleMinutes: 120, // extend or shrink timeout (min 1) + store: "~/state/clawdis-sessions.json" // optional custom path + } + } + } +} +``` + +Other session-related behaviors: +- `thinkingLevel` and `verboseLevel` persist per session so inline directives stick until the session resets. +- Heartbeats reuse the existing session for a recipient when available (good for keeping context warm). + +## Inspecting sessions + +- `clawdis status` shows the session store path, total count, and the five most recent keys with ages. +- `clawdis sessions` lists every session (filter with `--active ` or use `--json` for scripts). It also reports token usage per session; set `inbound.reply.agent.contextTokens` to see the budget percentage (defaults to ~200k tokens for Opus 4.5 via pi-ai defaults). +- For a deeper look, open the JSON store directly; the keys match the rules above. + +## Tips + +- Keep groups isolated: mention-based triggers plus the `group:` session key prevent group traffic from contaminating your DM history. +- If you automate cleanup, prefer deleting specific keys instead of the whole file to keep other conversations intact. diff --git a/package.json b/package.json index 14f32fbd3..21b7d8c82 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "packageManager": "pnpm@10.23.0", "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc.9", + "@mariozechner/pi-coding-agent": "^0.12.4", "body-parser": "^2.2.1", "chalk": "^5.6.2", "commander": "^14.0.2", diff --git a/src/agents/context.ts b/src/agents/context.ts new file mode 100644 index 000000000..061a16f8b --- /dev/null +++ b/src/agents/context.ts @@ -0,0 +1,34 @@ +// Lazy-load pi-ai model metadata so we can infer context windows when the agent +// reports a model id. pi-coding-agent depends on @mariozechner/pi-ai, so it +// should be present whenever CLAWDIS is installed from npm. + +type ModelEntry = { id: string; contextWindow?: number }; + +const MODEL_CACHE = new Map(); +const loadPromise = (async () => { + try { + const piAi = (await import("@mariozechner/pi-ai")) as { + getProviders: () => string[]; + getModels: (provider: string) => ModelEntry[]; + }; + const providers = piAi.getProviders(); + for (const p of providers) { + const models = piAi.getModels(p) as ModelEntry[]; + for (const m of models) { + if (!m?.id) continue; + if (typeof m.contextWindow === "number" && m.contextWindow > 0) { + MODEL_CACHE.set(m.id, m.contextWindow); + } + } + } + } catch { + // If pi-ai isn't available, leave cache empty; lookup will fall back. + } +})(); + +export function lookupContextTokens(modelId?: string): number | undefined { + if (!modelId) return undefined; + // Best-effort: kick off loading, but don't block. + void loadPromise; + return MODEL_CACHE.get(modelId); +} diff --git a/src/agents/defaults.ts b/src/agents/defaults.ts new file mode 100644 index 000000000..342c21ada --- /dev/null +++ b/src/agents/defaults.ts @@ -0,0 +1,5 @@ +// Defaults for agent metadata when upstream does not supply them. +// Model id uses pi-ai's built-in Anthropic catalog. +export const DEFAULT_MODEL = "claude-opus-4-5"; +// Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts). +export const DEFAULT_CONTEXT_TOKENS = 200_000; diff --git a/src/agents/pi-path.ts b/src/agents/pi-path.ts new file mode 100644 index 000000000..5a003b55c --- /dev/null +++ b/src/agents/pi-path.ts @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +// Resolve the bundled pi/tau binary path from the installed dependency. +export function resolveBundledPiBinary(): string | null { + try { + const require = createRequire(import.meta.url); + const pkgPath = require.resolve( + "@mariozechner/pi-coding-agent/package.json", + ); + const pkgDir = path.dirname(pkgPath); + // Prefer compiled binary if present, else fall back to dist/cli.js (has shebang). + const binCandidates = [ + path.join(pkgDir, "dist", "pi"), + path.join(pkgDir, "dist", "cli.js"), + path.join(pkgDir, "bin", "tau-dev.mjs"), + ]; + for (const candidate of binCandidates) { + if (fs.existsSync(candidate)) return candidate; + } + } catch { + // Dependency missing or resolution failed. + } + return null; +} diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 66e35c2b2..c4b2d7591 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -1,4 +1,7 @@ import crypto from "node:crypto"; +import { lookupContextTokens } from "../agents/context.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; +import { resolveBundledPiBinary } from "../agents/pi-path.js"; import { loadConfig, type WarelayConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, @@ -121,6 +124,28 @@ function stripMentions( return result.replace(/\s+/g, " ").trim(); } +function makeDefaultPiReply() { + const piBin = resolveBundledPiBinary() ?? "pi"; + const defaultContext = + lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS; + return { + mode: "command" as const, + command: [piBin, "--mode", "rpc", "{{BodyStripped}}"], + agent: { + kind: "pi" as const, + model: DEFAULT_MODEL, + contextTokens: defaultContext, + format: "json" as const, + }, + session: { + scope: "per-sender" as const, + resetTriggers: [DEFAULT_RESET_TRIGGER], + idleMinutes: DEFAULT_IDLE_MINUTES, + }, + timeoutSeconds: 600, + }; +} + export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, @@ -129,7 +154,7 @@ export async function getReplyFromConfig( ): Promise { // Choose reply from config: static text or external command stdout. const cfg = configOverride ?? loadConfig(); - const reply = cfg.inbound?.reply; + const reply = cfg.inbound?.reply ?? makeDefaultPiReply(); const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; let started = false; @@ -718,6 +743,49 @@ export async function getReplyFromConfig( ); } } + + const usage = meta.agentMeta?.usage; + const model = + meta.agentMeta?.model || + reply?.agent?.model || + sessionEntry?.model || + DEFAULT_MODEL; + const contextTokens = + reply?.agent?.contextTokens ?? + lookupContextTokens(model) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + + if (usage) { + const entry = sessionEntry ?? sessionStore[sessionKey]; + if (entry) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const total = usage.total ?? input + output; + sessionEntry = { + ...entry, + inputTokens: (entry.inputTokens ?? 0) + input, + outputTokens: (entry.outputTokens ?? 0) + output, + totalTokens: (entry.totalTokens ?? 0) + total, + model, + contextTokens: contextTokens ?? entry.contextTokens, + updatedAt: Date.now(), + }; + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + } else if (model || contextTokens) { + const entry = sessionEntry ?? sessionStore[sessionKey]; + if (entry) { + sessionEntry = { + ...entry, + model: model ?? entry.model, + contextTokens: contextTokens ?? entry.contextTokens, + }; + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + } } if (meta.agentMeta && isVerbose()) { logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`); diff --git a/src/cli/program.ts b/src/cli/program.ts index 38da3f90e..8265bad6d 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; import { sendCommand } from "../commands/send.js"; +import { sessionsCommand } from "../commands/sessions.js"; import { statusCommand } from "../commands/status.js"; import { loadConfig } from "../config/config.js"; import { danger, info, setVerbose } from "../globals.js"; @@ -490,6 +491,42 @@ Examples: } }); + program + .command("sessions") + .description("List stored conversation sessions") + .option("--json", "Output as JSON", false) + .option("--verbose", "Verbose logging", false) + .option( + "--store ", + "Path to session store (default: resolved from config)", + ) + .option( + "--active ", + "Only show sessions updated within the past N minutes", + ) + .addHelpText( + "after", + ` +Examples: + clawdis sessions # list all sessions + clawdis sessions --active 120 # only last 2 hours + clawdis sessions --json # machine-readable output + clawdis sessions --store ./tmp/sessions.json + +Shows token usage per session when the agent reports it; set inbound.reply.agent.contextTokens to see % of your model window.`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + await sessionsCommand( + { + json: Boolean(opts.json), + store: opts.store as string | undefined, + active: opts.active as string | undefined, + }, + defaultRuntime, + ); + }); + program .command("relay:tmux") .description( diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts new file mode 100644 index 000000000..8f1569635 --- /dev/null +++ b/src/commands/sessions.ts @@ -0,0 +1,167 @@ +import { lookupContextTokens } from "../agents/context.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; +import { info } from "../globals.js"; +import type { RuntimeEnv } from "../runtime.js"; + +type SessionRow = { + key: string; + kind: "direct" | "group" | "global" | "unknown"; + updatedAt: number | null; + ageMs: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + contextTokens?: number; +}; + +const formatAge = (ms: number | null | undefined) => { + if (!ms || ms < 0) return "unknown"; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +}; + +function classifyKey(key: string): SessionRow["kind"] { + if (key === "global") return "global"; + if (key.startsWith("group:")) return "group"; + if (key === "unknown") return "unknown"; + return "direct"; +} + +function toRows(store: Record): SessionRow[] { + return Object.entries(store) + .map(([key, entry]) => { + const updatedAt = entry?.updatedAt ?? null; + return { + key, + kind: classifyKey(key), + updatedAt, + ageMs: updatedAt ? Date.now() - updatedAt : null, + sessionId: entry?.sessionId, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: entry?.totalTokens, + model: entry?.model, + contextTokens: entry?.contextTokens, + } satisfies SessionRow; + }) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); +} + +export async function sessionsCommand( + opts: { json?: boolean; store?: string; active?: string }, + runtime: RuntimeEnv, +) { + const cfg = loadConfig(); + const configContextTokens = + cfg.inbound?.reply?.agent?.contextTokens ?? + lookupContextTokens(cfg.inbound?.reply?.agent?.model) ?? + DEFAULT_CONTEXT_TOKENS; + const configModel = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL; + const storePath = resolveStorePath( + opts.store ?? cfg.inbound?.reply?.session?.store, + ); + const store = loadSessionStore(storePath); + + const activeMinutes = opts.active + ? Number.parseInt(String(opts.active), 10) + : undefined; + if ( + opts.active !== undefined && + (Number.isNaN(activeMinutes) || activeMinutes <= 0) + ) { + runtime.error("--active must be a positive integer (minutes)"); + runtime.exit(1); + return; + } + + const rows = toRows(store).filter((row) => { + if (!activeMinutes) return true; + if (!row.updatedAt) return false; + return Date.now() - row.updatedAt <= activeMinutes * 60_000; + }); + + if (opts.json) { + runtime.log( + JSON.stringify( + { + path: storePath, + count: rows.length, + activeMinutes: activeMinutes ?? null, + sessions: rows.map((r) => ({ + ...r, + contextTokens: + r.contextTokens ?? + lookupContextTokens(r.model) ?? + configContextTokens ?? + null, + model: r.model ?? configModel ?? null, + })), + }, + null, + 2, + ), + ); + return; + } + + runtime.log(info(`Session store: ${storePath}`)); + runtime.log(info(`Sessions listed: ${rows.length}`)); + if (activeMinutes) { + runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`)); + } + if (rows.length === 0) { + runtime.log("No sessions found."); + return; + } + + for (const row of rows) { + const model = row.model ?? configModel; + const contextTokens = + row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens; + const input = row.inputTokens ?? 0; + const output = row.outputTokens ?? 0; + const total = row.totalTokens ?? input + output; + const pct = contextTokens + ? `${Math.min(100, Math.round((total / contextTokens) * 100))}%` + : null; + + const parts = [ + `${row.key} [${row.kind}]`, + row.updatedAt ? formatAge(Date.now() - row.updatedAt) : "age unknown", + ]; + if (row.sessionId) parts.push(`id ${row.sessionId}`); + if (row.thinkingLevel) parts.push(`think=${row.thinkingLevel}`); + if (row.verboseLevel) parts.push(`verbose=${row.verboseLevel}`); + if (row.systemSent) parts.push("systemSent"); + if (row.abortedLastRun) parts.push("aborted"); + if (total > 0) { + const tokenStr = `tokens in:${input} out:${output} total:${total}`; + parts.push( + contextTokens ? `${tokenStr} (${pct} of ${contextTokens})` : tokenStr, + ); + } + if (model) parts.push(`model=${model}`); + runtime.log(`- ${parts.join(" | ")}`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 5707660d2..b01a97b18 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -82,6 +82,8 @@ export type WarelayConfig = { kind: AgentKind; format?: "text" | "json"; identityPrefix?: string; + model?: string; + contextTokens?: number; }; }; }; @@ -141,6 +143,8 @@ const ReplySchema = z kind: z.literal("pi"), format: z.union([z.literal("text"), z.literal("json")]).optional(), identityPrefix: z.string().optional(), + model: z.string().optional(), + contextTokens: z.number().int().positive().optional(), }) .optional(), }) diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 3fb506b87..a512a61c9 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -15,6 +15,11 @@ export type SessionEntry = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + contextTokens?: number; }; export const SESSION_STORE_DEFAULT = path.join( diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 195a8588b..ebe19fc4e 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -148,6 +148,11 @@ export async function ensureFunnel( runtime.error( "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", ); + runtime.error( + info( + "Tip: Funnel is optional for CLAWDIS. You can keep running the web relay without it: `pnpm clawdis relay`", + ), + ); if (isVerbose()) { if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));