Compare commits

..

3 Commits

Author SHA1 Message Date
Peter Steinberger
ad28889a3d docs: expand acp usage 2026-01-18 06:34:23 +00:00
Peter Steinberger
3bd7615c4f refactor: split acp mappers 2026-01-18 06:22:33 +00:00
Peter Steinberger
41fbcc405f feat: add acp bridge 2026-01-18 06:07:00 +00:00
106 changed files with 1587 additions and 3973 deletions

View File

@@ -6,17 +6,14 @@ Docs: https://docs.clawd.bot
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- CLI: add `clawdbot acp` ACP bridge for IDE integrations.
### Fixes
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
## 2026.1.18-2

View File

@@ -760,10 +760,6 @@ public struct SessionsPatchParams: Codable, Sendable {
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable?
public let exechost: AnyCodable?
public let execsecurity: AnyCodable?
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable?
@@ -777,10 +773,6 @@ public struct SessionsPatchParams: Codable, Sendable {
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
exechost: AnyCodable?,
execsecurity: AnyCodable?,
execask: AnyCodable?,
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
sendpolicy: AnyCodable?,
@@ -793,10 +785,6 @@ public struct SessionsPatchParams: Codable, Sendable {
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.exechost = exechost
self.execsecurity = execsecurity
self.execask = execask
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
self.sendpolicy = sendpolicy
@@ -810,10 +798,6 @@ public struct SessionsPatchParams: Codable, Sendable {
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case exechost = "execHost"
case execsecurity = "execSecurity"
case execask = "execAsk"
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy"

194
docs.acp.md Normal file
View File

@@ -0,0 +1,194 @@
# Clawdbot ACP Bridge
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
## Overview
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
session keys so IDEs can reconnect to the same agent transcript or reset it on
request.
Key goals:
- Minimal ACP surface area (stdio, NDJSON).
- Stable session mapping across reconnects.
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
drive a Clawdbot Gateway session.
Quick steps:
1. Run a Gateway (local or remote).
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
3. Point the IDE to run `clawdbot acp` over stdio.
Example config:
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example run:
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Execution Model
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
- The bridge connects to the Gateway using existing auth config (or CLI flags).
- ACP `prompt` translates to Gateway `chat.send`.
- Gateway streaming events are translated back into ACP streaming events.
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
## Session Mapping
By default each ACP session is mapped to a dedicated Gateway session key:
- `acp:<uuid>` unless overridden.
You can override or reuse sessions in two ways:
1) CLI defaults
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session-label "support inbox"
clawdbot acp --reset-session
```
2) ACP metadata per session
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true,
"requireExisting": false
}
}
```
Rules:
- `sessionKey`: direct Gateway session key.
- `sessionLabel`: resolve an existing session by label.
- `resetSession`: mint a new transcript for the key before first use.
- `requireExisting`: fail if the key/label does not exist.
### Session Listing
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
sessions returned.
## Prompt Translation
ACP prompt inputs are converted into a Gateway `chat.send`:
- `text` and `resource` blocks become prompt text.
- `resource_link` with image mime types become attachments.
- The working directory can be prefixed into the prompt (default on, can be
disabled with `--no-prefix-cwd`).
Gateway streaming events are translated into ACP `message` and `tool_call`
updates. Terminal Gateway states map to ACP `done` with stop reasons:
- `complete` -> `stop`
- `aborted` -> `cancel`
- `error` -> `error`
## Auth + Gateway Discovery
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
- `--url` / `--token` / `--password` take precedence.
- Otherwise use configured `gateway.remote.*` settings.
## Operational Notes
- ACP sessions are stored in memory for the bridge process lifetime.
- Gateway session state is persisted by the Gateway itself.
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
- ACP runs can be canceled and the active run id is tracked per session.
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
## Testing
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
## Related Docs
- CLI usage: `docs/cli/acp.md`
- Session model: `docs/concepts/session.md`
- Session management internals: `docs/reference/session-management-compaction.md`

143
docs/cli/acp.md Normal file
View File

@@ -0,0 +1,143 @@
---
summary: "Run the ACP bridge for IDE integrations"
read_when:
- Setting up ACP-based IDE integrations
- Debugging ACP session routing to the Gateway
---
# acp
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
## Usage
```bash
clawdbot acp
# Remote Gateway
clawdbot acp --url wss://gateway-host:18789 --token <token>
# Attach to an existing session key
clawdbot acp --session agent:main:main
# Attach by label (must already exist)
clawdbot acp --session-label "support inbox"
# Reset the session key before the first prompt
clawdbot acp --session agent:main:main --reset-session
```
## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
it to drive a Clawdbot Gateway session.
1. Ensure the Gateway is running (local or remote).
2. Configure the Gateway target (config or flags).
3. Point your IDE to run `clawdbot acp` over stdio.
Example config (persisted):
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example direct run (no config write):
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zeds Settings UI):
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Session mapping
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
To reuse a known session, pass a session key or label:
- `--session <key>`: use a specific Gateway session key.
- `--session-label <label>`: resolve an existing session by label.
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
If your ACP client supports metadata, you can override per session:
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true
}
}
```
Learn more about session keys at [/concepts/session](/concepts/session).
## Options
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
- `--token <token>`: Gateway auth token.
- `--password <password>`: Gateway auth password.
- `--session <key>`: default session key.
- `--session-label <label>`: default session label to resolve.
- `--require-existing`: fail if the session key/label does not exist.
- `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr.

View File

@@ -29,12 +29,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (2/2 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**Example (verbose):**
@@ -272,4 +271,4 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil
```
**See:** [SOUL Evil Hook](/hooks/soul-evil)
**See:** [soul-evil documentation](/hooks#soul-evil)

View File

@@ -23,6 +23,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`message`](/cli/message)
- [`agent`](/cli/agent)
- [`agents`](/cli/agents)
- [`acp`](/cli/acp)
- [`status`](/cli/status)
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
@@ -125,6 +126,7 @@ clawdbot [--dev] [--profile <name>] <command>
list
add
delete
acp
status
health
sessions
@@ -506,6 +508,11 @@ Options:
- `--force`
- `--json`
### `acp`
Run the ACP bridge that connects IDEs to the Gateway.
See [`acp`](/cli/acp) for full options and examples.
### `status`
Show linked session health and recent recipients.

View File

@@ -54,12 +54,8 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecycle
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -97,18 +93,7 @@ Send these as standalone messages so they register.
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
reset: {
// Defaults: mode=daily, atHour=4 (gateway host local time).
// If you also set idleMinutes, whichever expires first wins.
mode: "daily",
atHour: 4,
idleMinutes: 120
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
idleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main",

View File

@@ -956,8 +956,6 @@
{
"group": "Automation & Hooks",
"pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",

View File

@@ -146,11 +146,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior
session: {
scope: "per-sender",
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
idleMinutes: 60,
heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",

View File

@@ -2416,7 +2416,7 @@ Notes:
### `session`
Controls session scoping, reset policy, reset triggers, and where the session store is written.
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
```json5
{
@@ -2426,16 +2426,7 @@ Controls session scoping, reset policy, reset triggers, and where the session st
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
idleMinutes: 60,
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
@@ -2446,12 +2437,12 @@ Controls session scoping, reset policy, reset triggers, and where the session st
// Max ping-pong reply turns between requester/target (05).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
sendPolicy: {
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } }
],
default: "allow"
}
],
default: "allow"
}
}
}
```
@@ -2465,13 +2456,6 @@ Fields:
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
- `atHour`: local hour (0-23) for the daily reset boundary.
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.

View File

@@ -239,15 +239,11 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/
```
**Check 2:** Is the reset window too short?
**Check 2:** Is `idleMinutes` too short?
```json
{
"session": {
"reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
"idleMinutes": 10080 // 7 days
}
}
```

View File

@@ -37,11 +37,10 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with three bundled hooks that are automatically discovered:
Clawdbot ships with two bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -512,8 +511,6 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:

View File

@@ -1,68 +0,0 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
Clawdbot logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
clawdbot hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 01): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/hooks)

View File

@@ -82,8 +82,7 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.

View File

@@ -160,11 +160,7 @@ Example:
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 10080
}
idleMinutes: 10080
}
}
```

View File

@@ -880,19 +880,14 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`?
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
You can also add an idle window; when both daily and idle resets are configured,
whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
message starts a fresh session id for that chat key. This does not delete
transcripts — it just starts a new session.
```json5
{
session: {
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
idleMinutes: 240
}
}
```

View File

@@ -41,16 +41,6 @@ Notes:
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
## Session overrides (`/exec`)
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
Send `/exec` with no arguments to show the current values.
Example:
```
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
## Exec approvals (macOS app)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.

View File

@@ -12,7 +12,7 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems:
- **Commands**: standalone `/...` messages.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`.
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
@@ -77,7 +77,6 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)

View File

@@ -1,14 +0,0 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
}
}

View File

@@ -1,281 +0,0 @@
/**
* Memory Plugin E2E Tests
*
* Tests the memory plugin functionality including:
* - Plugin registration and configuration
* - Memory storage and retrieval
* - Auto-recall via hooks
* - Auto-capture filtering
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
// Skip if no OpenAI API key
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
describeWithKey("memory plugin e2e", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory plugin registers and initializes correctly", async () => {
// Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory");
expect(memoryPlugin.name).toBe("Memory (Vector)");
expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function);
});
test("config schema parses valid config", async () => {
const { default: memoryPlugin } = await import("./index.js");
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: true,
autoRecall: true,
});
expect(config).toBeDefined();
expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY);
expect(config?.dbPath).toBe(dbPath);
});
test("config schema resolves env vars", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Set a test env var
process.env.TEST_MEMORY_API_KEY = "test-key-123";
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: "${TEST_MEMORY_API_KEY}",
},
dbPath,
});
expect(config?.embedding?.apiKey).toBe("test-key-123");
delete process.env.TEST_MEMORY_API_KEY;
});
test("config schema rejects missing apiKey", async () => {
const { default: memoryPlugin } = await import("./index.js");
expect(() => {
memoryPlugin.configSchema?.parse?.({
embedding: {},
dbPath,
});
}).toThrow("embedding.apiKey is required");
});
test("shouldCapture filters correctly", async () => {
// Test the capture filtering logic by checking the rules
const triggers = [
{ text: "I prefer dark mode", shouldMatch: true },
{ text: "Remember that my name is John", shouldMatch: true },
{ text: "My email is test@example.com", shouldMatch: true },
{ text: "Call me at +1234567890123", shouldMatch: true },
{ text: "We decided to use TypeScript", shouldMatch: true },
{ text: "I always want verbose output", shouldMatch: true },
{ text: "Just a random short message", shouldMatch: false },
{ text: "x", shouldMatch: false }, // Too short
{ text: "<relevant-memories>injected</relevant-memories>", shouldMatch: false }, // Skip injected
];
// The shouldCapture function is internal, but we can test via the capture behavior
// For now, just verify the patterns we expect to match
for (const { text, shouldMatch } of triggers) {
const hasPreference = /prefer|radši|like|love|hate|want/i.test(text);
const hasRemember = /zapamatuj|pamatuj|remember/i.test(text);
const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text);
const hasPhone = /\+\d{10,}/.test(text);
const hasDecision = /rozhodli|decided|will use|budeme/i.test(text);
const hasAlways = /always|never|important/i.test(text);
const isInjected = text.includes("<relevant-memories>");
const isTooShort = text.length < 10;
const wouldCapture =
!isTooShort &&
!isInjected &&
(hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways);
if (shouldMatch) {
expect(wouldCapture).toBe(true);
}
}
});
test("detectCategory classifies correctly", async () => {
// Test category detection patterns
const cases = [
{ text: "I prefer dark mode", expected: "preference" },
{ text: "We decided to use React", expected: "decision" },
{ text: "My email is test@example.com", expected: "entity" },
{ text: "The server is running on port 3000", expected: "fact" },
];
for (const { text, expected } of cases) {
const lower = text.toLowerCase();
let category: string;
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
category = "preference";
} else if (/rozhodli|decided|will use|budeme/i.test(lower)) {
category = "decision";
} else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
category = "entity";
} else if (/is|are|has|have|je|má|jsou/i.test(lower)) {
category = "fact";
} else {
category = "other";
}
expect(category).toBe(expected);
}
});
});
// Live tests that require OpenAI API key and actually use LanceDB
describeWithKey("memory plugin live tests", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Mock plugin API
const registeredTools: any[] = [];
const registeredClis: any[] = [];
const registeredServices: any[] = [];
const registeredHooks: Record<string, any[]> = {};
const logs: string[] = [];
const mockApi = {
id: "memory",
name: "Memory (Vector)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: (msg: string) => logs.push(`[info] ${msg}`),
warn: (msg: string) => logs.push(`[warn] ${msg}`),
error: (msg: string) => logs.push(`[error] ${msg}`),
debug: (msg: string) => logs.push(`[debug] ${msg}`),
},
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
registerCli: (registrar: any, opts: any) => {
registeredClis.push({ registrar, opts });
},
registerService: (service: any) => {
registeredServices.push(service);
},
on: (hookName: string, handler: any) => {
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
registeredHooks[hookName].push(handler);
},
resolvePath: (p: string) => p,
};
// Register plugin
await memoryPlugin.register(mockApi as any);
// Check registration
expect(registeredTools.length).toBe(3);
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget");
expect(registeredClis.length).toBe(1);
expect(registeredServices.length).toBe(1);
// Get tool functions
const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool;
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
// Test store
const storeResult = await storeTool.execute("test-call-1", {
text: "The user prefers dark mode for all applications",
importance: 0.8,
category: "preference",
});
expect(storeResult.details?.action).toBe("created");
expect(storeResult.details?.id).toBeDefined();
const storedId = storeResult.details?.id;
// Test recall
const recallResult = await recallTool.execute("test-call-2", {
query: "dark mode preference",
limit: 5,
});
expect(recallResult.details?.count).toBeGreaterThan(0);
expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode");
// Test duplicate detection
const duplicateResult = await storeTool.execute("test-call-3", {
text: "The user prefers dark mode for all applications",
});
expect(duplicateResult.details?.action).toBe("duplicate");
// Test forget
const forgetResult = await forgetTool.execute("test-call-4", {
memoryId: storedId,
});
expect(forgetResult.details?.action).toBe("deleted");
// Verify it's gone
const recallAfterForget = await recallTool.execute("test-call-5", {
query: "dark mode preference",
limit: 5,
});
expect(recallAfterForget.details?.count).toBe(0);
}, 60000); // 60s timeout for live API calls
});

View File

@@ -1,690 +0,0 @@
/**
* Clawdbot Memory Plugin
*
* Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings.
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
*/
import { Type } from "@sinclair/typebox";
import * as lancedb from "@lancedb/lancedb";
import OpenAI from "openai";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
// ============================================================================
// Types
// ============================================================================
type MemoryConfig = {
embedding: {
provider: "openai";
model?: string;
apiKey: string;
};
dbPath?: string;
autoCapture?: boolean;
autoRecall?: boolean;
};
const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
type MemoryEntry = {
id: string;
text: string;
vector: number[];
importance: number;
category: MemoryCategory;
createdAt: number;
};
type MemorySearchResult = {
entry: MemoryEntry;
score: number;
};
// ============================================================================
// Config Schema
// ============================================================================
const memoryConfigSchema = {
parse(value: unknown): MemoryConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("memory config required");
}
const cfg = value as Record<string, unknown>;
// Embedding config is required
const embedding = cfg.embedding as Record<string, unknown> | undefined;
if (!embedding || typeof embedding.apiKey !== "string") {
throw new Error("embedding.apiKey is required");
}
const model =
typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small";
ensureSupportedEmbeddingModel(model);
return {
embedding: {
provider: "openai",
model,
apiKey: resolveEnvVars(embedding.apiKey),
},
dbPath:
typeof cfg.dbPath === "string"
? cfg.dbPath
: join(homedir(), ".clawdbot", "memory", "lancedb"),
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
};
},
uiHints: {
"embedding.apiKey": {
label: "OpenAI API Key",
sensitive: true,
placeholder: "sk-proj-...",
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
},
"embedding.model": {
label: "Embedding Model",
placeholder: "text-embedding-3-small",
help: "OpenAI embedding model to use",
},
dbPath: {
label: "Database Path",
placeholder: "~/.clawdbot/memory/lancedb",
advanced: true,
},
autoCapture: {
label: "Auto-Capture",
help: "Automatically capture important information from conversations",
},
autoRecall: {
label: "Auto-Recall",
help: "Automatically inject relevant memories into context",
},
},
};
const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
};
function ensureSupportedEmbeddingModel(model: string): void {
if (!EMBEDDING_DIMENSIONS[model]) {
throw new Error(`Unsupported embedding model: ${model}`);
}
}
function stringEnum<T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
function resolveEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}
// ============================================================================
// LanceDB Provider
// ============================================================================
const TABLE_NAME = "memories";
class MemoryDB {
private db: lancedb.Connection | null = null;
private table: lancedb.Table | null = null;
private initPromise: Promise<void> | null = null;
constructor(
private readonly dbPath: string,
private readonly vectorDim: number,
) {}
private async ensureInitialized(): Promise<void> {
if (this.table) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this.doInitialize();
return this.initPromise;
}
private async doInitialize(): Promise<void> {
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();
if (tables.includes(TABLE_NAME)) {
this.table = await this.db.openTable(TABLE_NAME);
} else {
this.table = await this.db.createTable(TABLE_NAME, [
{
id: "__schema__",
text: "",
vector: new Array(this.vectorDim).fill(0),
importance: 0,
category: "other",
createdAt: 0,
},
]);
await this.table.delete('id = "__schema__"');
}
}
async store(
entry: Omit<MemoryEntry, "id" | "createdAt">,
): Promise<MemoryEntry> {
await this.ensureInitialized();
const fullEntry: MemoryEntry = {
...entry,
id: randomUUID(),
createdAt: Date.now(),
};
await this.table!.add([fullEntry]);
return fullEntry;
}
async search(
vector: number[],
limit = 5,
minScore = 0.5,
): Promise<MemorySearchResult[]> {
await this.ensureInitialized();
const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
// LanceDB uses L2 distance by default; convert to similarity score
const mapped = results.map((row) => {
const distance = row._distance ?? 0;
// Use inverse for a 0-1 range: sim = 1 / (1 + d)
const score = 1 / (1 + distance);
return {
entry: {
id: row.id as string,
text: row.text as string,
vector: row.vector as number[],
importance: row.importance as number,
category: row.category as MemoryEntry["category"],
createdAt: row.createdAt as number,
},
score,
};
});
return mapped.filter((r) => r.score >= minScore);
}
async delete(id: string): Promise<boolean> {
await this.ensureInitialized();
// Validate UUID format to prevent injection
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
throw new Error(`Invalid memory ID format: ${id}`);
}
await this.table!.delete(`id = '${id}'`);
return true;
}
async count(): Promise<number> {
await this.ensureInitialized();
return this.table!.countRows();
}
}
// ============================================================================
// OpenAI Embeddings
// ============================================================================
class Embeddings {
private client: OpenAI;
constructor(
apiKey: string,
private model: string,
) {
this.client = new OpenAI({ apiKey });
}
async embed(text: string): Promise<number[]> {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
});
return response.data[0].embedding;
}
}
// ============================================================================
// Rule-based capture filter
// ============================================================================
const MEMORY_TRIGGERS = [
/zapamatuj si|pamatuj|remember/i,
/preferuji|radši|nechci|prefer/i,
/rozhodli jsme|budeme používat/i,
/\+\d{10,}/,
/[\w.-]+@[\w.-]+\.\w+/,
/můj\s+\w+\s+je|je\s+můj/i,
/my\s+\w+\s+is|is\s+my/i,
/i (like|prefer|hate|love|want|need)/i,
/always|never|important/i,
];
function shouldCapture(text: string): boolean {
if (text.length < 10 || text.length > 500) return false;
// Skip injected context from memory recall
if (text.includes("<relevant-memories>")) return false;
// Skip system-generated content
if (text.startsWith("<") && text.includes("</")) return false;
// Skip agent summary responses (contain markdown formatting)
if (text.includes("**") && text.includes("\n-")) return false;
// Skip emoji-heavy responses (likely agent output)
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
if (emojiCount > 3) return false;
return MEMORY_TRIGGERS.some((r) => r.test(text));
}
function detectCategory(text: string): MemoryCategory {
const lower = text.toLowerCase();
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision";
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower))
return "entity";
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact";
return "other";
}
// ============================================================================
// Plugin Definition
// ============================================================================
const memoryPlugin = {
id: "memory",
name: "Memory (Vector)",
description: "Long-term memory with vector search and seamless auto-recall/capture",
kind: "memory" as const,
configSchema: memoryConfigSchema,
register(api: ClawdbotPluginApi) {
const cfg = memoryConfigSchema.parse(api.pluginConfig);
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
const vectorDim = EMBEDDING_DIMENSIONS[cfg.embedding.model ?? "text-embedding-3-small"];
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
// ========================================================================
// Tools
// ========================================================================
api.registerTool(
{
name: "memory_recall",
label: "Memory Recall",
description:
"Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
}),
async execute(_toolCallId, params) {
const { query, limit = 5 } = params as { query: string; limit?: number };
const vector = await embeddings.embed(query);
const results = await db.search(vector, limit, 0.1);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: { count: 0 },
};
}
const text = results
.map(
(r, i) =>
`${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`,
)
.join("\n");
// Strip vector data for serialization (typed arrays can't be cloned)
const sanitizedResults = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
importance: r.entry.importance,
score: r.score,
}));
return {
content: [
{ type: "text", text: `Found ${results.length} memories:\n\n${text}` },
],
details: { count: results.length, memories: sanitizedResults },
};
},
},
{ name: "memory_recall" },
);
api.registerTool(
{
name: "memory_store",
label: "Memory Store",
description:
"Save important information in long-term memory. Use for preferences, facts, decisions.",
parameters: Type.Object({
text: Type.String({ description: "Information to remember" }),
importance: Type.Optional(
Type.Number({ description: "Importance 0-1 (default: 0.7)" }),
),
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
}),
async execute(_toolCallId, params) {
const {
text,
importance = 0.7,
category = "other",
} = params as {
text: string;
importance?: number;
category?: MemoryEntry["category"];
};
const vector = await embeddings.embed(text);
// Check for duplicates
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) {
return {
content: [
{ type: "text", text: `Similar memory already exists: "${existing[0].entry.text}"` },
],
details: { action: "duplicate", existingId: existing[0].entry.id, existingText: existing[0].entry.text },
};
}
const entry = await db.store({
text,
vector,
importance,
category,
});
return {
content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
details: { action: "created", id: entry.id },
};
},
},
{ name: "memory_store" },
);
api.registerTool(
{
name: "memory_forget",
label: "Memory Forget",
description: "Delete specific memories. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(Type.String({ description: "Search to find memory" })),
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
}),
async execute(_toolCallId, params) {
const { query, memoryId } = params as { query?: string; memoryId?: string };
if (memoryId) {
await db.delete(memoryId);
return {
content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
details: { action: "deleted", id: memoryId },
};
}
if (query) {
const vector = await embeddings.embed(query);
const results = await db.search(vector, 5, 0.7);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No matching memories found." }],
details: { found: 0 },
};
}
if (results.length === 1 && results[0].score > 0.9) {
await db.delete(results[0].entry.id);
return {
content: [
{ type: "text", text: `Forgotten: "${results[0].entry.text}"` },
],
details: { action: "deleted", id: results[0].entry.id },
};
}
const list = results
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
.join("\n");
// Strip vector data for serialization
const sanitizedCandidates = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
score: r.score,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
},
],
details: { action: "candidates", candidates: sanitizedCandidates },
};
}
return {
content: [{ type: "text", text: "Provide query or memoryId." }],
details: { error: "missing_param" },
};
},
},
{ name: "memory_forget" },
);
// ========================================================================
// CLI Commands
// ========================================================================
api.registerCli(
({ program }) => {
const memory = program
.command("ltm")
.description("Long-term memory plugin commands");
memory
.command("list")
.description("List memories")
.action(async () => {
const count = await db.count();
console.log(`Total memories: ${count}`);
});
memory
.command("search")
.description("Search memories")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "5")
.action(async (query, opts) => {
const vector = await embeddings.embed(query);
const results = await db.search(vector, parseInt(opts.limit), 0.3);
// Strip vectors for output
const output = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
importance: r.entry.importance,
score: r.score,
}));
console.log(JSON.stringify(output, null, 2));
});
memory
.command("stats")
.description("Show memory statistics")
.action(async () => {
const count = await db.count();
console.log(`Total memories: ${count}`);
});
},
{ commands: ["ltm"] },
);
// ========================================================================
// Lifecycle Hooks
// ========================================================================
// Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) {
api.on("before_agent_start", async (event) => {
if (!event.prompt || event.prompt.length < 5) return;
try {
const vector = await embeddings.embed(event.prompt);
const results = await db.search(vector, 3, 0.3);
if (results.length === 0) return;
const memoryContext = results
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
.join("\n");
api.logger.info?.(
`memory: injecting ${results.length} memories into context`,
);
return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
};
} catch (err) {
api.logger.warn(`memory: recall failed: ${String(err)}`);
}
});
}
// Auto-capture: analyze and store important information after agent ends
if (cfg.autoCapture) {
api.on("agent_end", async (event) => {
if (!event.success || !event.messages || event.messages.length === 0) {
return;
}
try {
// Extract text content from messages (handling unknown[] type)
const texts: string[] = [];
for (const msg of event.messages) {
// Type guard for message object
if (!msg || typeof msg !== "object") continue;
const msgObj = msg as Record<string, unknown>;
// Only process user and assistant messages
const role = msgObj.role;
if (role !== "user" && role !== "assistant") continue;
const content = msgObj.content;
// Handle string content directly
if (typeof content === "string") {
texts.push(content);
continue;
}
// Handle array content (content blocks)
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as Record<string, unknown>).type === "text" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
texts.push((block as Record<string, unknown>).text as string);
}
}
}
}
// Filter for capturable content
const toCapture = texts.filter(
(text) => text && shouldCapture(text),
);
if (toCapture.length === 0) return;
// Store each capturable piece (limit to 3 per conversation)
let stored = 0;
for (const text of toCapture.slice(0, 3)) {
const category = detectCategory(text);
const vector = await embeddings.embed(text);
// Check for duplicates (high similarity threshold)
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) continue;
await db.store({
text,
vector,
importance: 0.7,
category,
});
stored++;
}
if (stored > 0) {
api.logger.info(`memory: auto-captured ${stored} memories`);
}
} catch (err) {
api.logger.warn(`memory: capture failed: ${String(err)}`);
}
});
}
// ========================================================================
// Service
// ========================================================================
api.registerService({
id: "memory",
start: () => {
api.logger.info(
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
);
},
stop: () => {
api.logger.info("memory: stopped");
},
});
},
};
export default memoryPlugin;

View File

@@ -1,14 +0,0 @@
{
"name": "@clawdbot/memory",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": {
"@sinclair/typebox": "0.34.47",
"@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
},
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -140,6 +140,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@buape/carbon": "0.0.0-beta-20260110172854",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",

349
pnpm-lock.yaml generated
View File

@@ -13,6 +13,9 @@ importers:
.:
dependencies:
'@agentclientprotocol/sdk':
specifier: 0.13.0
version: 0.13.0(zod@4.3.5)
'@buape/carbon':
specifier: 0.0.0-beta-20260110172854
version: 0.0.0-beta-20260110172854(hono@4.11.4)
@@ -258,24 +261,6 @@ importers:
specifier: 40.0.0
version: 40.0.0
extensions/memory:
dependencies:
'@lancedb/lancedb':
specifier: ^0.15.0
version: 0.15.0(apache-arrow@18.1.0)
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
openai:
specifier: ^4.77.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
extensions/memory-core:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/msteams:
dependencies:
'@microsoft/agents-hosting':
@@ -357,6 +342,11 @@ importers:
packages:
'@agentclientprotocol/sdk@0.13.0':
resolution: {integrity: sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@anthropic-ai/sdk@0.71.2':
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
hasBin: true
@@ -970,62 +960,6 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lancedb/lancedb-darwin-arm64@0.15.0':
resolution: {integrity: sha512-e6eiS1dUdSx3G3JXFEn5bk6I26GR7UM2QwQ1YMrTsg7IvGDqKmXc/s5j4jpJH0mzm7rwqh+OAILPIjr7DoUCDA==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [darwin]
'@lancedb/lancedb-darwin-x64@0.15.0':
resolution: {integrity: sha512-kEgigrqKf954egDbUdIp86tjVfFmTCTcq2Hydw/WLc+LI++46aeT2MsJv0CQpkNFMfh/T2G18FsDYLKH0zTaow==}
engines: {node: '>= 18'}
cpu: [x64]
os: [darwin]
'@lancedb/lancedb-linux-arm64-gnu@0.15.0':
resolution: {integrity: sha512-TnpbBT9kaSYQqastJ+S5jm4S5ZYBx18X8PHQ1ic3yMIdPTjCWauj+owDovOpiXK9ucjmi/FnUp8bKNxGnlqmEg==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
'@lancedb/lancedb-linux-arm64-musl@0.15.0':
resolution: {integrity: sha512-fe8LnC9YKbLgEJiLQhyVj+xz1d1RgWKs+rLSYPxaD3xQBo3kMC94Esq+xfrdNkSFvPgchRTvBA9jDYJjJL8rcg==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
'@lancedb/lancedb-linux-x64-gnu@0.15.0':
resolution: {integrity: sha512-0lKEc3M06ax3RozBbxHuNN9qWqhJUiKDnRC3ttsbmo4VrOUBvAO3fKoaRkjZhAA8q4+EdhZnCaQZezsk60f7Ag==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
'@lancedb/lancedb-linux-x64-musl@0.15.0':
resolution: {integrity: sha512-ls+ikV7vWyVnqVT7bMmuqfGCwVR5JzPIfJ5iZ4rkjU4iTIQRpY7u/cTe9rGKt/+psliji8x6PPZHpfdGXHmleQ==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
'@lancedb/lancedb-win32-arm64-msvc@0.15.0':
resolution: {integrity: sha512-C30A+nDaJ4jhjN76hRcp28Eq+G48SR9wO3i1zGm0ZAEcRV1t9O1fAp6g18IPT65Qyu/hXJBgBdVHtent+qg9Ng==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [win32]
'@lancedb/lancedb-win32-x64-msvc@0.15.0':
resolution: {integrity: sha512-amXzIAxqrHyp+c9TpIDI8ze1uCqWC6HXQIoXkoMQrBXoUUo8tJORH2yGAsa3TSgjZDDjg0HPA33dYLhOLk1m8g==}
engines: {node: '>= 18'}
cpu: [x64]
os: [win32]
'@lancedb/lancedb@0.15.0':
resolution: {integrity: sha512-qm3GXLA17/nFGUwrOEuFNW0Qg2gvCtp+yAs6qoCM6vftIreqzp8d4Hio6eG/YojS9XqPnR2q+zIeIFy12Ywvxg==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@lit-labs/signals@0.2.0':
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
@@ -2022,9 +1956,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
'@thi.ng/bitstream@2.4.37':
resolution: {integrity: sha512-ghVt+/73cChlhHDNQH9+DnxvoeVYYBu7AYsS0Gvwq25fpCa4LaqnEk5LAJfsY043HInwcV7/0KGO7P+XZCzumQ==}
engines: {node: '>=18'}
@@ -2059,12 +1990,6 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/command-line-usage@5.0.4':
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -2116,18 +2041,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
'@types/node@24.10.7':
resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==}
@@ -2270,10 +2186,6 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -2318,10 +2230,6 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
apache-arrow@18.1.0:
resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==}
hasBin: true
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
@@ -2333,14 +2241,6 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-back@3.1.0:
resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
engines: {node: '>=6'}
array-back@6.2.2:
resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
engines: {node: '>=12.17'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2458,10 +2358,6 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
engines: {node: '>=12'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2547,14 +2443,6 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'}
command-line-usage@7.0.3:
resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
engines: {node: '>=12.20.0'}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
@@ -2835,13 +2723,6 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
find-replace@3.0.0:
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
engines: {node: '>=4.0.0'}
flatbuffers@24.12.23:
resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
@@ -2855,17 +2736,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -3024,9 +2898,6 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -3154,10 +3025,6 @@ packages:
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
@@ -3307,9 +3174,6 @@ packages:
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
@@ -3615,18 +3479,6 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.10.0:
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true
@@ -3915,9 +3767,6 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -4180,10 +4029,6 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -4287,14 +4132,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typical@4.0.0:
resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
engines: {node: '>=8'}
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -4308,12 +4145,6 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -4451,10 +4282,6 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -4490,10 +4317,6 @@ packages:
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4581,6 +4404,10 @@ packages:
snapshots:
'@agentclientprotocol/sdk@0.13.0(zod@4.3.5)':
dependencies:
zod: 4.3.5
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
@@ -5402,44 +5229,6 @@ snapshots:
'@kwsites/promise-deferred@1.1.1':
optional: true
'@lancedb/lancedb-darwin-arm64@0.15.0':
optional: true
'@lancedb/lancedb-darwin-x64@0.15.0':
optional: true
'@lancedb/lancedb-linux-arm64-gnu@0.15.0':
optional: true
'@lancedb/lancedb-linux-arm64-musl@0.15.0':
optional: true
'@lancedb/lancedb-linux-x64-gnu@0.15.0':
optional: true
'@lancedb/lancedb-linux-x64-musl@0.15.0':
optional: true
'@lancedb/lancedb-win32-arm64-msvc@0.15.0':
optional: true
'@lancedb/lancedb-win32-x64-msvc@0.15.0':
optional: true
'@lancedb/lancedb@0.15.0(apache-arrow@18.1.0)':
dependencies:
apache-arrow: 18.1.0
reflect-metadata: 0.2.2
optionalDependencies:
'@lancedb/lancedb-darwin-arm64': 0.15.0
'@lancedb/lancedb-darwin-x64': 0.15.0
'@lancedb/lancedb-linux-arm64-gnu': 0.15.0
'@lancedb/lancedb-linux-arm64-musl': 0.15.0
'@lancedb/lancedb-linux-x64-gnu': 0.15.0
'@lancedb/lancedb-linux-x64-musl': 0.15.0
'@lancedb/lancedb-win32-arm64-msvc': 0.15.0
'@lancedb/lancedb-win32-x64-msvc': 0.15.0
'@lit-labs/signals@0.2.0':
dependencies:
lit: 3.3.2
@@ -6515,10 +6304,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.18':
dependencies:
tslib: 2.8.1
'@thi.ng/bitstream@2.4.37':
dependencies:
'@thi.ng/errors': 2.6.0
@@ -6562,10 +6347,6 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/command-line-args@5.2.3': {}
'@types/command-line-usage@5.0.4': {}
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.0.7
@@ -6627,21 +6408,8 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.0.7
form-data: 4.0.5
'@types/node@10.17.60': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.30':
dependencies:
undici-types: 6.21.0
'@types/node@24.10.7':
dependencies:
undici-types: 7.16.0
@@ -6846,10 +6614,6 @@ snapshots:
agent-base@7.1.4: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -6885,18 +6649,6 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
apache-arrow@18.1.0:
dependencies:
'@swc/helpers': 0.5.18
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 20.19.30
command-line-args: 5.2.1
command-line-usage: 7.0.3
flatbuffers: 24.12.23
json-bignum: 0.0.3
tslib: 2.8.1
aproba@2.1.0:
optional: true
@@ -6908,10 +6660,6 @@ snapshots:
argparse@2.0.1: {}
array-back@3.1.0: {}
array-back@6.2.2: {}
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.10:
@@ -7048,10 +6796,6 @@ snapshots:
chai@6.2.2: {}
chalk-template@0.4.0:
dependencies:
chalk: 4.1.2
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -7161,20 +6905,6 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
command-line-args@5.2.1:
dependencies:
array-back: 3.1.0
find-replace: 3.0.0
lodash.camelcase: 4.3.0
typical: 4.0.0
command-line-usage@7.0.3:
dependencies:
array-back: 6.2.2
chalk-template: 0.4.0
table-layout: 4.1.1
typical: 7.3.0
commander@10.0.1:
optional: true
@@ -7472,12 +7202,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-replace@3.0.0:
dependencies:
array-back: 3.1.0
flatbuffers@24.12.23: {}
follow-redirects@1.15.11(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
@@ -7487,8 +7211,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -7497,11 +7219,6 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -7699,10 +7416,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -7826,8 +7539,6 @@ snapshots:
dependencies:
bignumber.js: 9.3.1
json-bignum@0.0.3: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.6
@@ -7988,8 +7699,6 @@ snapshots:
lit-element: 4.2.2
lit-html: 3.3.2
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8:
@@ -8333,21 +8042,6 @@ snapshots:
mimic-function: 5.0.1
optional: true
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.19.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
openai@6.10.0(ws@8.19.0)(zod@4.3.5):
optionalDependencies:
ws: 8.19.0
@@ -8678,8 +8372,6 @@ snapshots:
real-require@0.2.0: {}
reflect-metadata@0.2.2: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -9022,11 +8714,6 @@ snapshots:
dependencies:
has-flag: 4.0.0
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
wordwrapjs: 5.1.1
tailwind-merge@3.4.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.17):
@@ -9114,10 +8801,6 @@ snapshots:
typescript@5.9.3: {}
typical@4.0.0: {}
typical@7.3.0: {}
uc.micro@2.1.0: {}
uhtml@5.0.9:
@@ -9128,10 +8811,6 @@ snapshots:
uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.18.2: {}
@@ -9233,8 +8912,6 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {}
@@ -9273,8 +8950,6 @@ snapshots:
wordwrap@1.0.0: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
describe("acp event mapper", () => {
it("extracts text and resource blocks into prompt text", () => {
const text = extractTextFromPrompt([
{ type: "text", text: "Hello" },
{ type: "resource", resource: { text: "File contents" } },
{ type: "resource_link", uri: "https://example.com", title: "Spec" },
{ type: "image", data: "abc", mimeType: "image/png" },
]);
expect(text).toBe(
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
);
});
it("extracts image blocks into gateway attachments", () => {
const attachments = extractAttachmentsFromPrompt([
{ type: "image", data: "abc", mimeType: "image/png" },
{ type: "image", data: "", mimeType: "image/png" },
{ type: "text", text: "ignored" },
]);
expect(attachments).toEqual([
{
type: "image",
mimeType: "image/png",
content: "abc",
},
]);
});
});

73
src/acp/event-mapper.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
export type GatewayAttachment = {
type: string;
mimeType: string;
content: string;
};
export function extractTextFromPrompt(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text") {
parts.push(block.text);
continue;
}
if (block.type === "resource") {
const resource = block.resource as { text?: string } | undefined;
if (resource?.text) parts.push(resource.text);
continue;
}
if (block.type === "resource_link") {
const title = block.title ? ` (${block.title})` : "";
const uri = block.uri ?? "";
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
parts.push(line);
}
}
return parts.join("\n");
}
export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
const attachments: GatewayAttachment[] = [];
for (const block of prompt) {
if (block.type !== "image") continue;
const image = block as ImageContent;
if (!image.data || !image.mimeType) continue;
attachments.push({
type: "image",
mimeType: image.mimeType,
content: image.data,
});
}
return attachments;
}
export function formatToolTitle(
name: string | undefined,
args: Record<string, unknown> | undefined,
): string {
const base = name ?? "tool";
if (!args || Object.keys(args).length === 0) return base;
const parts = Object.entries(args).map(([key, value]) => {
const raw = typeof value === "string" ? value : JSON.stringify(value);
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
return `${key}: ${safe}`;
});
return `${base}: ${parts.join(", ")}`;
}
export function inferToolKind(name?: string): ToolKind | undefined {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("search") || normalized.includes("find")) return "search";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
return "execute";
}
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
return "other";
}

4
src/acp/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { serveAcpGateway } from "./server.js";
export { createInMemorySessionStore } from "./session.js";
export type { AcpSessionStore } from "./session.js";
export type { AcpServerOptions } from "./types.js";

35
src/acp/meta.ts Normal file
View File

@@ -0,0 +1,35 @@
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "boolean") return value;
}
return undefined;
}
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}

149
src/acp/server.ts Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env node
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isMainModule } from "../infra/is-main.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl,
});
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const token =
opts.gatewayToken ??
(isRemoteMode ? remote?.token?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
auth.token;
const password =
opts.gatewayPassword ??
(isRemoteMode ? remote?.password?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
auth.password;
let agent: AcpGatewayAgent | null = null;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
password: password || undefined,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
void agent?.handleGatewayEvent(evt);
},
onHelloOk: () => {
agent?.handleGatewayReconnect();
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
},
});
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
new AgentSideConnection((conn) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;
}, stream);
gateway.start();
}
function parseArgs(args: string[]): AcpServerOptions {
const opts: AcpServerOptions = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
opts.gatewayUrl = args[i + 1];
i += 1;
continue;
}
if (arg === "--token" || arg === "--gateway-token") {
opts.gatewayToken = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
continue;
}
if (arg === "--session-label") {
opts.defaultSessionLabel = args[i + 1];
i += 1;
continue;
}
if (arg === "--require-existing") {
opts.requireExistingSession = true;
continue;
}
if (arg === "--reset-session") {
opts.resetSession = true;
continue;
}
if (arg === "--no-prefix-cwd") {
opts.prefixCwd = false;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
return opts;
}
function printHelp(): void {
console.log(`Usage: clawdbot acp [options]
Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--password <password> Gateway auth password
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
function createGateway(resolveLabelKey = "agent:main:label"): {
gateway: GatewayClient;
request: ReturnType<typeof vi.fn>;
} {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.resolve" && "label" in params) {
return { ok: true, key: resolveLabelKey };
}
if (method === "sessions.resolve" && "key" in params) {
return { ok: true, key: params.key as string };
}
return { ok: true };
});
return {
gateway: { request } as unknown as GatewayClient,
request,
};
}
describe("acp session mapper", () => {
it("prefers explicit sessionLabel over sessionKey", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: {},
});
expect(key).toBe("agent:main:label");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
});
it("lets meta sessionKey override default label", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: { defaultSessionLabel: "default-label" },
});
expect(key).toBe("agent:main:override");
expect(request).not.toHaveBeenCalled();
});
});

95
src/acp/session-mapper.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { GatewayClient } from "../gateway/client.js";
import type { AcpServerOptions } from "./types.js";
import { readBool, readString } from "./meta.js";
export type AcpSessionMeta = {
sessionKey?: string;
sessionLabel?: string;
resetSession?: boolean;
requireExisting?: boolean;
prefixCwd?: boolean;
};
export function parseSessionMeta(meta: unknown): AcpSessionMeta {
if (!meta || typeof meta !== "object") return {};
const record = meta as Record<string, unknown>;
return {
sessionKey: readString(record, ["sessionKey", "session", "key"]),
sessionLabel: readString(record, ["sessionLabel", "label"]),
resetSession: readBool(record, ["resetSession", "reset"]),
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
prefixCwd: readBool(record, ["prefixCwd"]),
};
}
export async function resolveSessionKey(params: {
meta: AcpSessionMeta;
fallbackKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<string> {
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
const requireExisting =
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
if (params.meta.sessionLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: params.meta.sessionLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
}
return resolved.key;
}
if (params.meta.sessionKey) {
if (!requireExisting) return params.meta.sessionKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: params.meta.sessionKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
}
return resolved.key;
}
if (requestedLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: requestedLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
}
return resolved.key;
}
if (requestedKey) {
if (!requireExisting) return requestedKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: requestedKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${requestedKey}`);
}
return resolved.key;
}
return params.fallbackKey;
}
export async function resetSessionIfNeeded(params: {
meta: AcpSessionMeta;
sessionKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<void> {
const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
if (!resetSession) return;
await params.gateway.request("sessions.reset", { key: params.sessionKey });
}

26
src/acp/session.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it, afterEach } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
const store = createInMemorySessionStore();
afterEach(() => {
store.clearAllSessionsForTest();
});
it("tracks active runs and clears on cancel", () => {
const session = store.createSession({
sessionKey: "acp:test",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-1", controller);
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
const cancelled = store.cancelActiveRun(session.sessionId);
expect(cancelled).toBe(true);
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
});

93
src/acp/session.ts Normal file
View File

@@ -0,0 +1,93 @@
import { randomUUID } from "node:crypto";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
}) => AcpSession;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
clearActiveRun: (sessionId: string) => void;
cancelActiveRun: (sessionId: string) => boolean;
clearAllSessionsForTest: () => void;
};
export function createInMemorySessionStore(): AcpSessionStore {
const sessions = new Map<string, AcpSession>();
const runIdToSessionId = new Map<string, string>();
const createSession: AcpSessionStore["createSession"] = (params) => {
const sessionId = params.sessionId ?? randomUUID();
const session: AcpSession = {
sessionId,
sessionKey: params.sessionKey,
cwd: params.cwd,
createdAt: Date.now(),
abortController: null,
activeRunId: null,
};
sessions.set(sessionId, session);
return session;
};
const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
const sessionId = runIdToSessionId.get(runId);
return sessionId ? sessions.get(sessionId) : undefined;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (
sessionId,
runId,
abortController,
) => {
const session = sessions.get(sessionId);
if (!session) return;
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
};
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return;
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.activeRunId = null;
session.abortController = null;
};
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) return false;
session.abortController.abort();
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.abortController = null;
session.activeRunId = null;
return true;
};
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
for (const session of sessions.values()) {
session.abortController?.abort();
}
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
clearAllSessionsForTest,
};
}
export const defaultAcpSessionStore = createInMemorySessionStore();

420
src/acp/translator.ts Normal file
View File

@@ -0,0 +1,420 @@
import { randomUUID } from "node:crypto";
import type {
Agent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
import { readBool, readNumber, readString } from "./meta.js";
import {
extractAttachmentsFromPrompt,
extractTextFromPrompt,
formatToolTitle,
inferToolKind,
} from "./event-mapper.js";
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js";
type PendingPrompt = {
sessionId: string;
sessionKey: string;
idempotencyKey: string;
resolve: (response: PromptResponse) => void;
reject: (err: Error) => void;
sentTextLength?: number;
sentText?: string;
toolCalls?: Set<string>;
};
type AcpGatewayAgentOptions = AcpServerOptions & {
sessionStore?: AcpSessionStore;
};
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
private opts: AcpGatewayAgentOptions;
private log: (msg: string) => void;
private sessionStore: AcpSessionStore;
private pendingPrompts = new Map<string, PendingPrompt>();
constructor(
connection: AgentSideConnection,
gateway: GatewayClient,
opts: AcpGatewayAgentOptions = {},
) {
this.connection = connection;
this.gateway = gateway;
this.opts = opts;
this.log = opts.verbose
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
: () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
}
start(): void {
this.log("ready");
}
handleGatewayReconnect(): void {
this.log("gateway reconnected");
}
handleGatewayDisconnect(reason: string): void {
this.log(`gateway disconnected: ${reason}`);
for (const pending of this.pendingPrompts.values()) {
pending.reject(new Error(`Gateway disconnected: ${reason}`));
this.sessionStore.clearActiveRun(pending.sessionId);
}
this.pendingPrompts.clear();
}
async handleGatewayEvent(evt: EventFrame): Promise<void> {
if (evt.event === "chat") {
await this.handleChatEvent(evt);
return;
}
if (evt.event === "agent") {
await this.handleAgentEvent(evt);
}
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: false,
embeddedContext: true,
},
mcpCapabilities: {
http: false,
sse: false,
},
sessionCapabilities: {
list: {},
},
},
agentInfo: ACP_AGENT_INFO,
authMethods: [],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: `acp:${sessionId}`,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
return { sessionId: session.sessionId };
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: params.sessionId,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId: params.sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
return {};
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
const limit = readNumber(params._meta, ["limit"]) ?? 100;
const result = await this.gateway.request<SessionsListResult>("sessions.list", { limit });
const cwd = params.cwd ?? process.cwd();
return {
sessions: result.sessions.map((session) => ({
sessionId: session.key,
cwd,
title: session.displayName ?? session.label ?? session.key,
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
_meta: {
sessionKey: session.key,
kind: session.kind,
channel: session.channel,
},
})),
nextCursor: null,
};
}
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
return {};
}
async setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse | void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (!params.modeId) return {};
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
thinkingLevel: params.modeId,
});
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
} catch (err) {
this.log(`setSessionMode error: ${String(err)}`);
}
return {};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (session.abortController) {
this.sessionStore.cancelActiveRun(params.sessionId);
}
const abortController = new AbortController();
const runId = randomUUID();
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
const meta = parseSessionMeta(params._meta);
const userText = extractTextFromPrompt(params.prompt);
const attachments = extractAttachmentsFromPrompt(params.prompt);
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
return new Promise<PromptResponse>((resolve, reject) => {
this.pendingPrompts.set(params.sessionId, {
sessionId: params.sessionId,
sessionKey: session.sessionKey,
idempotencyKey: runId,
resolve,
reject,
});
this.gateway
.request(
"chat.send",
{
sessionKey: session.sessionKey,
message,
attachments: attachments.length > 0 ? attachments : undefined,
idempotencyKey: runId,
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
},
{ expectFinal: true },
)
.catch((err) => {
this.pendingPrompts.delete(params.sessionId);
this.sessionStore.clearActiveRun(params.sessionId);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
async cancel(params: CancelNotification): Promise<void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) return;
this.sessionStore.cancelActiveRun(params.sessionId);
try {
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
} catch (err) {
this.log(`cancel error: ${String(err)}`);
}
const pending = this.pendingPrompts.get(params.sessionId);
if (pending) {
this.pendingPrompts.delete(params.sessionId);
pending.resolve({ stopReason: "cancelled" });
}
}
private async handleAgentEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const stream = payload.stream as string | undefined;
const data = payload.data as Record<string, unknown> | undefined;
const sessionKey = payload.sessionKey as string | undefined;
if (!stream || !data || !sessionKey) return;
if (stream !== "tool") return;
const phase = data.phase as string | undefined;
const name = data.name as string | undefined;
const toolCallId = data.toolCallId as string | undefined;
if (!toolCallId) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (phase === "start") {
if (!pending.toolCalls) pending.toolCalls = new Set();
if (pending.toolCalls.has(toolCallId)) return;
pending.toolCalls.add(toolCallId);
const args = data.args as Record<string, unknown> | undefined;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: formatToolTitle(name, args),
status: "in_progress",
rawInput: args,
kind: inferToolKind(name),
},
});
return;
}
if (phase === "result") {
const isError = Boolean(data.isError);
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: isError ? "failed" : "completed",
rawOutput: data.result,
},
});
}
}
private async handleChatEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const sessionKey = payload.sessionKey as string | undefined;
const state = payload.state as string | undefined;
const runId = payload.runId as string | undefined;
const messageData = payload.message as Record<string, unknown> | undefined;
if (!sessionKey || !state) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (runId && pending.idempotencyKey !== runId) return;
if (state === "delta" && messageData) {
await this.handleDeltaEvent(pending.sessionId, messageData);
return;
}
if (state === "final") {
this.finishPrompt(pending.sessionId, pending, "end_turn");
return;
}
if (state === "aborted") {
this.finishPrompt(pending.sessionId, pending, "cancelled");
return;
}
if (state === "error") {
this.finishPrompt(pending.sessionId, pending, "refusal");
}
}
private async handleDeltaEvent(
sessionId: string,
messageData: Record<string, unknown>,
): Promise<void> {
const content = messageData.content as Array<{ type: string; text?: string }> | undefined;
const fullText = content?.find((c) => c.type === "text")?.text ?? "";
const pending = this.pendingPrompts.get(sessionId);
if (!pending) return;
const sentSoFar = pending.sentTextLength ?? 0;
if (fullText.length <= sentSoFar) return;
const newText = fullText.slice(sentSoFar);
pending.sentTextLength = fullText.length;
pending.sentText = fullText;
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: newText },
},
});
}
private finishPrompt(
sessionId: string,
pending: PendingPrompt,
stopReason: StopReason,
): void {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
pending.resolve({ stopReason });
}
private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
for (const pending of this.pendingPrompts.values()) {
if (pending.sessionKey === sessionKey) return pending;
}
return undefined;
}
}

30
src/acp/types.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
cwd: string;
createdAt: number;
abortController: AbortController | null;
activeRunId: string | null;
};
export type AcpServerOptions = {
gatewayUrl?: string;
gatewayToken?: string;
gatewayPassword?: string;
defaultSessionKey?: string;
defaultSessionLabel?: string;
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
verbose?: boolean;
};
export const ACP_AGENT_INFO = {
name: "clawdbot-acp",
title: "Clawdbot ACP Gateway",
version: VERSION,
};

View File

@@ -1,9 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import {
clearInternalHooks,
registerInternalHook,
@@ -28,7 +29,7 @@ describe("resolveBootstrapFilesForRun", () => {
];
});
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.some((file) => file.name === "EXTRA.md")).toBe(true);
@@ -53,7 +54,7 @@ describe("resolveBootstrapContextForRun", () => {
];
});
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
const result = await resolveBootstrapContextForRun({ workspaceDir });
const extra = result.contextFiles.find((file) => file.path === "EXTRA.md");

View File

@@ -8,14 +8,6 @@ import {
import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
export function makeBootstrapWarn(params: {
sessionLabel: string;
warn?: (message: string) => void;
}): ((message: string) => void) | undefined {
if (!params.warn) return undefined;
return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`);
}
export async function resolveBootstrapFilesForRun(params: {
workspaceDir: string;
config?: ClawdbotConfig;

View File

@@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import {
appendImagePathsToPrompt,
@@ -73,7 +73,7 @@ export async function runCliAgent(params: {
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,

View File

@@ -16,7 +16,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { resolveBootstrapContextForRun } from "../bootstrap-files.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -181,7 +181,7 @@ export async function compactEmbeddedPiSession(params: {
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({

View File

@@ -218,7 +218,6 @@ export async function runEmbeddedPiAgent(
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
execOverrides: params.execOverrides,
bashElevated: params.bashElevated,
timeoutMs: params.timeoutMs,
runId: params.runId,

View File

@@ -17,7 +17,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import {
isCloudCodeAssistFormatError,
@@ -64,9 +64,8 @@ import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -127,14 +126,14 @@ export async function runEmbeddedAttempt(
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const toolsRaw = createClawdbotCodingTools({
exec: {
...params.execOverrides,
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
@@ -459,40 +458,9 @@ export async function runEmbeddedAttempt(
}
}
// Get hook runner once for both before_agent_start and agent_end hooks
const hookRunner = getGlobalHookRunner();
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
// Run before_agent_start hooks to allow plugins to inject context
let effectivePrompt = params.prompt;
if (hookRunner?.hasHooks("before_agent_start")) {
try {
const hookResult = await hookRunner.runBeforeAgentStart(
{
prompt: params.prompt,
messages: activeSession.messages,
},
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
);
if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`;
log.debug(
`hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`,
);
}
} catch (hookErr) {
log.warn(`before_agent_start hook failed: ${String(hookErr)}`);
}
}
log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`);
// Repair orphaned trailing user messages so new prompts don't violate role ordering.
@@ -512,7 +480,7 @@ export async function runEmbeddedAttempt(
}
try {
await abortable(activeSession.prompt(effectivePrompt, { images: params.images }));
await abortable(activeSession.prompt(params.prompt, { images: params.images }));
} catch (err) {
promptError = err;
} finally {
@@ -533,29 +501,6 @@ export async function runEmbeddedAttempt(
messagesSnapshot = activeSession.messages.slice();
sessionIdUsed = activeSession.sessionId;
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await
if (hookRunner?.hasHooks("agent_end")) {
hookRunner
.runAgentEnd(
{
messages: messagesSnapshot,
success: !aborted && !promptError,
error: promptError ? describeUnknownError(promptError) : undefined,
durationMs: Date.now() - promptStartedAt,
},
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
)
.catch((err) => {
log.warn(`agent_end hook failed: ${err}`);
});
}
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) clearTimeout(abortWarnTimer);

View File

@@ -2,7 +2,7 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
@@ -34,7 +34,6 @@ export type RunEmbeddedPiAgentParams = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;

View File

@@ -4,7 +4,7 @@ import type { discoverAuthStorage, discoverModels } from "@mariozechner/pi-codin
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
@@ -39,7 +39,6 @@ export type EmbeddedRunAttemptParams = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;

View File

@@ -108,19 +108,13 @@ export function handleMessageUpdate(
})
.trim();
if (next && next !== ctx.state.lastStreamedAssistant) {
const previousText = ctx.state.lastStreamedAssistant ?? "";
ctx.state.lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(next);
const { text: previousCleanedText } = parseReplyDirectives(previousText);
const deltaText = cleanedText.startsWith(previousCleanedText)
? cleanedText.slice(previousCleanedText.length)
: cleanedText;
emitAgentEvent({
runId: ctx.params.runId,
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
@@ -128,7 +122,6 @@ export function handleMessageUpdate(
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});

View File

@@ -146,42 +146,4 @@ describe("subscribeEmbeddedPiSession", () => {
expect(combined).toBe("Final answer");
},
);
it("emits delta chunks in agent events for streaming assistant text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " world" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads[0]?.text).toBe("Hello");
expect(payloads[0]?.delta).toBe("Hello");
expect(payloads[1]?.text).toBe("Hello world");
expect(payloads[1]?.delta).toBe(" world");
});
});

View File

@@ -367,20 +367,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "exec",
nativeName: "exec",
description: "Set exec defaults for this session.",
textAlias: "/exec",
args: [
{
name: "options",
description: "host=... security=... ask=... node=...",
type: "string",
},
],
argsParsing: "none",
}),
defineChatCommand({
key: "model",
nativeName: "model",

View File

@@ -147,47 +147,6 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("shows current exec defaults when /exec has no argument", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/exec",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
node: "mac-1",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain(
"Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.",
);
expect(text).toContain(
"Options: host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("persists elevated off and reflects it in /status (even when default is on)", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { extractStatusDirective } from "./reply/directives.js";
import {
extractElevatedDirective,
extractExecDirective,
extractQueueDirective,
extractReasoningDirective,
extractReplyToTag,
@@ -113,26 +112,6 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("");
});
it("matches exec directive with options", () => {
const res = extractExecDirective(
"please /exec host=gateway security=allowlist ask=on-miss node=mac-mini now",
);
expect(res.hasDirective).toBe(true);
expect(res.execHost).toBe("gateway");
expect(res.execSecurity).toBe("allowlist");
expect(res.execAsk).toBe("on-miss");
expect(res.execNode).toBe("mac-mini");
expect(res.cleaned).toBe("please now");
});
it("captures invalid exec host values", () => {
const res = extractExecDirective("/exec host=spaceship");
expect(res.hasDirective).toBe(true);
expect(res.execHost).toBeUndefined();
expect(res.rawExecHost).toBe("spaceship");
expect(res.invalidHost).toBe(true);
});
it("matches queue directive", () => {
const res = extractQueueDirective("please /queue interrupt now");
expect(res.hasDirective).toBe(true);

View File

@@ -5,7 +5,6 @@ export {
extractVerboseDirective,
} from "./reply/directives.js";
export { getReplyFromConfig } from "./reply/get-reply.js";
export { extractExecDirective } from "./reply/exec.js";
export { extractQueueDirective } from "./reply/queue.js";
export { extractReplyToTag } from "./reply/reply-tags.js";
export type { GetReplyOptions, ReplyPayload } from "./types.js";

View File

@@ -226,7 +226,6 @@ export async function runAgentTurnWithFallback(params: {
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,

View File

@@ -123,7 +123,6 @@ export async function runMemoryFlushIfNeeded(params: {
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId: flushRunId,

View File

@@ -1,13 +1,8 @@
import {
resolveAgentConfig,
resolveAgentDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
@@ -28,36 +23,6 @@ import {
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
function resolveExecDefaults(params: {
cfg: ClawdbotConfig;
sessionEntry?: SessionEntry;
agentId?: string;
}): { host: ExecHost; security: ExecSecurity; ask: ExecAsk; node?: string } {
const globalExec = params.cfg.tools?.exec;
const agentExec = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.exec
: undefined;
return {
host:
(params.sessionEntry?.execHost as ExecHost | undefined) ??
(agentExec?.host as ExecHost | undefined) ??
(globalExec?.host as ExecHost | undefined) ??
"sandbox",
security:
(params.sessionEntry?.execSecurity as ExecSecurity | undefined) ??
(agentExec?.security as ExecSecurity | undefined) ??
(globalExec?.security as ExecSecurity | undefined) ??
"deny",
ask:
(params.sessionEntry?.execAsk as ExecAsk | undefined) ??
(agentExec?.ask as ExecAsk | undefined) ??
(globalExec?.ask as ExecAsk | undefined) ??
"on-miss",
node:
(params.sessionEntry?.execNode as string | undefined) ?? agentExec?.node ?? globalExec?.node,
};
}
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
@@ -224,42 +189,6 @@ export async function handleDirectiveOnly(params: {
}),
};
}
if (directives.hasExecDirective) {
if (directives.invalidExecHost) {
return {
text: `Unrecognized exec host "${directives.rawExecHost ?? ""}". Valid hosts: sandbox, gateway, node.`,
};
}
if (directives.invalidExecSecurity) {
return {
text: `Unrecognized exec security "${directives.rawExecSecurity ?? ""}". Valid: deny, allowlist, full.`,
};
}
if (directives.invalidExecAsk) {
return {
text: `Unrecognized exec ask "${directives.rawExecAsk ?? ""}". Valid: off, on-miss, always.`,
};
}
if (directives.invalidExecNode) {
return {
text: "Exec node requires a value.",
};
}
if (!directives.hasExecOptions) {
const execDefaults = resolveExecDefaults({
cfg: params.cfg,
sessionEntry,
agentId: activeAgentId,
});
const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)";
return {
text: withOptions(
`Current exec defaults: host=${execDefaults.host}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`,
"host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>",
),
};
}
}
const queueAck = maybeHandleQueueDirective({
directives,
@@ -325,20 +254,6 @@ export async function handleDirectiveOnly(params: {
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
}
if (modelSelection) {
if (modelSelection.isDefault) {
delete sessionEntry.providerOverride;
@@ -440,16 +355,6 @@ export async function handleDirectiveOnly(params: {
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}
if (directives.hasExecDirective && directives.hasExecOptions) {
const execParts: string[] = [];
if (directives.execHost) execParts.push(`host=${directives.execHost}`);
if (directives.execSecurity) execParts.push(`security=${directives.execSecurity}`);
if (directives.execAsk) execParts.push(`ask=${directives.execAsk}`);
if (directives.execNode) execParts.push(`node=${directives.execNode}`);
if (execParts.length > 0) {
parts.push(formatDirectiveAck(`Exec defaults set (${execParts.join(", ")}).`));
}
}
if (shouldDowngradeXHigh) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,

View File

@@ -1,11 +1,9 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
import {
extractElevatedDirective,
extractExecDirective,
extractReasoningDirective,
extractStatusDirective,
extractThinkDirective,
@@ -29,20 +27,6 @@ export type InlineDirectives = {
hasElevatedDirective: boolean;
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasExecDirective: boolean;
execHost?: ExecHost;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;
rawExecHost?: string;
rawExecSecurity?: string;
rawExecAsk?: string;
rawExecNode?: string;
hasExecOptions: boolean;
invalidExecHost: boolean;
invalidExecSecurity: boolean;
invalidExecAsk: boolean;
invalidExecNode: boolean;
hasStatusDirective: boolean;
hasModelDirective: boolean;
rawModelDirective?: string;
@@ -99,27 +83,10 @@ export function parseInlineDirectives(
hasDirective: false,
}
: extractElevatedDirective(reasoningCleaned);
const {
cleaned: execCleaned,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidHost: invalidExecHost,
invalidSecurity: invalidExecSecurity,
invalidAsk: invalidExecAsk,
invalidNode: invalidExecNode,
hasDirective: hasExecDirective,
} = extractExecDirective(elevatedCleaned);
const allowStatusDirective = options?.allowStatusDirective !== false;
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective
? extractStatusDirective(execCleaned)
: { cleaned: execCleaned, hasDirective: false };
? extractStatusDirective(elevatedCleaned)
: { cleaned: elevatedCleaned, hasDirective: false };
const {
cleaned: modelCleaned,
rawModel,
@@ -157,20 +124,6 @@ export function parseInlineDirectives(
hasElevatedDirective,
elevatedLevel,
rawElevatedLevel,
hasExecDirective,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidExecHost,
invalidExecSecurity,
invalidExecAsk,
invalidExecNode,
hasStatusDirective,
hasModelDirective,
rawModelDirective: rawModel,
@@ -203,7 +156,6 @@ export function isDirectiveOnly(params: {
!directives.hasVerboseDirective &&
!directives.hasReasoningDirective &&
!directives.hasElevatedDirective &&
!directives.hasExecDirective &&
!directives.hasModelDirective &&
!directives.hasQueueDirective
)

View File

@@ -118,24 +118,6 @@ export async function persistInlineDirectives(params: {
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
updated = true;
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
updated = true;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
updated = true;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
updated = true;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
updated = true;
}
}
const modelDirective =
directives.hasModelDirective && params.effectiveModelDirective

View File

@@ -153,4 +153,3 @@ export function extractStatusDirective(body?: string): {
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js";

View File

@@ -1 +0,0 @@
export { extractExecDirective } from "./exec/directive.js";

View File

@@ -1,202 +0,0 @@
import type { ExecAsk, ExecHost, ExecSecurity } from "../../../infra/exec-approvals.js";
type ExecDirectiveParse = {
cleaned: string;
hasDirective: boolean;
execHost?: ExecHost;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;
rawExecHost?: string;
rawExecSecurity?: string;
rawExecAsk?: string;
rawExecNode?: string;
hasExecOptions: boolean;
invalidHost: boolean;
invalidSecurity: boolean;
invalidAsk: boolean;
invalidNode: boolean;
};
function normalizeExecHost(value?: string): ExecHost | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node")
return normalized;
return undefined;
}
function normalizeExecSecurity(value?: string): ExecSecurity | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full")
return normalized;
return undefined;
}
function normalizeExecAsk(value?: string): ExecAsk | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return undefined;
}
function parseExecDirectiveArgs(raw: string): Omit<
ExecDirectiveParse,
"cleaned" | "hasDirective"
> & {
consumed: number;
} {
let i = 0;
const len = raw.length;
while (i < len && /\s/.test(raw[i])) i += 1;
if (raw[i] === ":") {
i += 1;
while (i < len && /\s/.test(raw[i])) i += 1;
}
let consumed = i;
let execHost: ExecHost | undefined;
let execSecurity: ExecSecurity | undefined;
let execAsk: ExecAsk | undefined;
let execNode: string | undefined;
let rawExecHost: string | undefined;
let rawExecSecurity: string | undefined;
let rawExecAsk: string | undefined;
let rawExecNode: string | undefined;
let hasExecOptions = false;
let invalidHost = false;
let invalidSecurity = false;
let invalidAsk = false;
let invalidNode = false;
const takeToken = (): string | null => {
if (i >= len) return null;
const start = i;
while (i < len && !/\s/.test(raw[i])) i += 1;
if (start === i) return null;
const token = raw.slice(start, i);
while (i < len && /\s/.test(raw[i])) i += 1;
return token;
};
const splitToken = (token: string): { key: string; value: string } | null => {
const eq = token.indexOf("=");
const colon = token.indexOf(":");
const idx = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon);
if (idx === -1) return null;
const key = token.slice(0, idx).trim().toLowerCase();
const value = token.slice(idx + 1).trim();
if (!key) return null;
return { key, value };
};
while (i < len) {
const token = takeToken();
if (!token) break;
const parsed = splitToken(token);
if (!parsed) break;
const { key, value } = parsed;
if (key === "host") {
rawExecHost = value;
execHost = normalizeExecHost(value);
if (!execHost) invalidHost = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "security") {
rawExecSecurity = value;
execSecurity = normalizeExecSecurity(value);
if (!execSecurity) invalidSecurity = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "ask") {
rawExecAsk = value;
execAsk = normalizeExecAsk(value);
if (!execAsk) invalidAsk = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "node") {
rawExecNode = value;
const trimmed = value.trim();
if (!trimmed) {
invalidNode = true;
} else {
execNode = trimmed;
}
hasExecOptions = true;
consumed = i;
continue;
}
break;
}
return {
consumed,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidHost,
invalidSecurity,
invalidAsk,
invalidNode,
};
}
export function extractExecDirective(body?: string): ExecDirectiveParse {
if (!body) {
return {
cleaned: "",
hasDirective: false,
hasExecOptions: false,
invalidHost: false,
invalidSecurity: false,
invalidAsk: false,
invalidNode: false,
};
}
const re = /(?:^|\s)\/exec(?=$|\s|:)/i;
const match = re.exec(body);
if (!match) {
return {
cleaned: body.trim(),
hasDirective: false,
hasExecOptions: false,
invalidHost: false,
invalidSecurity: false,
invalidAsk: false,
invalidNode: false,
};
}
const start = match.index + match[0].indexOf("/exec");
const argsStart = start + "/exec".length;
const parsed = parseExecDirectiveArgs(body.slice(argsStart));
const cleanedRaw = `${body.slice(0, start)} ${body.slice(argsStart + parsed.consumed)}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
hasDirective: true,
execHost: parsed.execHost,
execSecurity: parsed.execSecurity,
execAsk: parsed.execAsk,
execNode: parsed.execNode,
rawExecHost: parsed.rawExecHost,
rawExecSecurity: parsed.rawExecSecurity,
rawExecAsk: parsed.rawExecAsk,
rawExecNode: parsed.rawExecNode,
hasExecOptions: parsed.hasExecOptions,
invalidHost: parsed.invalidHost,
invalidSecurity: parsed.invalidSecurity,
invalidAsk: parsed.invalidAsk,
invalidNode: parsed.invalidNode,
};
}

View File

@@ -158,7 +158,6 @@ export function createFollowupRunner(params: {
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
reasoningLevel: queued.run.reasoningLevel,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,

View File

@@ -110,20 +110,6 @@ export async function applyInlineDirectiveOverrides(params: {
hasVerboseDirective: false,
hasReasoningDirective: false,
hasElevatedDirective: false,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
hasStatusDirective: false,
hasModelDirective: false,
hasQueueDirective: false,
@@ -220,7 +206,6 @@ export async function applyInlineDirectiveOverrides(params: {
directives.hasVerboseDirective ||
directives.hasReasoningDirective ||
directives.hasElevatedDirective ||
directives.hasExecDirective ||
directives.hasModelDirective ||
directives.hasQueueDirective ||
directives.hasStatusDirective;

View File

@@ -15,20 +15,6 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives {
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
hasStatusDirective: false,
hasModelDirective: false,
rawModelDirective: undefined,

View File

@@ -1,4 +1,3 @@
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
@@ -22,7 +21,6 @@ import { stripInlineStatus } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
export type ReplyDirectiveContinuation = {
commandSource: string;
@@ -40,7 +38,6 @@ export type ReplyDirectiveContinuation = {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
execOverrides?: ExecOverrides;
blockStreamingEnabled: boolean;
blockReplyChunking?: {
minChars: number;
@@ -62,21 +59,6 @@ export type ReplyDirectiveContinuation = {
};
};
function resolveExecOverrides(params: {
directives: InlineDirectives;
sessionEntry?: SessionEntry;
}): ExecOverrides | undefined {
const host =
params.directives.execHost ?? (params.sessionEntry?.execHost as ExecOverrides["host"]);
const security =
params.directives.execSecurity ??
(params.sessionEntry?.execSecurity as ExecOverrides["security"]);
const ask = params.directives.execAsk ?? (params.sessionEntry?.execAsk as ExecOverrides["ask"]);
const node = params.directives.execNode ?? params.sessionEntry?.execNode;
if (!host && !security && !ask && !node) return undefined;
return { host, security, ask, node };
}
export type ReplyDirectiveResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| { kind: "continue"; result: ReplyDirectiveContinuation };
@@ -208,33 +190,11 @@ export async function resolveReplyDirectives(params: {
};
}
}
if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasExecDirective) {
if (parsedDirectives.execSecurity !== "deny") {
parsedDirectives = {
...parsedDirectives,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
};
}
}
const hasInlineDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
parsedDirectives.hasReasoningDirective ||
parsedDirectives.hasElevatedDirective ||
parsedDirectives.hasExecDirective ||
parsedDirectives.hasModelDirective ||
parsedDirectives.hasQueueDirective;
if (hasInlineDirective) {
@@ -445,7 +405,6 @@ export async function resolveReplyDirectives(params: {
model = applyResult.model;
contextTokens = applyResult.contextTokens;
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
const execOverrides = resolveExecOverrides({ directives, sessionEntry });
return {
kind: "continue",
@@ -465,7 +424,6 @@ export async function resolveReplyDirectives(params: {
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,

View File

@@ -10,7 +10,6 @@ import {
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionFilePath,
@@ -48,7 +47,6 @@ import type { TypingController } from "./typing.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
const BARE_SESSION_RESET_PROMPT =
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
@@ -71,7 +69,6 @@ type RunPreparedReplyParams = {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
execOverrides?: ExecOverrides;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
blockStreamingEnabled: boolean;
@@ -230,7 +227,6 @@ export async function runPreparedReply(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
abortedLastRun,
} = params;
let currentSystemSent = systemSent;
@@ -434,7 +430,6 @@ export async function runPreparedReply(
verboseLevel: resolvedVerboseLevel,
reasoningLevel: resolvedReasoningLevel,
elevatedLevel: resolvedElevatedLevel,
execOverrides,
bashElevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,

View File

@@ -157,7 +157,6 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
@@ -242,7 +241,6 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
elevatedEnabled,
elevatedAllowed,
blockStreamingEnabled,

View File

@@ -3,7 +3,6 @@ import type { ClawdbotConfig } from "../../../config/config.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { OriginatingChannelType } from "../../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue";
@@ -57,7 +56,6 @@ export type FollowupRun = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
elevatedLevel?: ElevatedLevel;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: {
enabled: boolean;
allowed: boolean;

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { saveSessionStore } from "../../config/sessions.js";
@@ -170,241 +170,3 @@ describe("initSessionState RawBody", () => {
expect(result.triggerBodyNormalized).toBe("/status");
});
});
describe("initSessionState reset policy", () => {
it("defaults to daily reset at 4am local time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S1";
const existingSessionId = "daily-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 3, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S-edge";
const existingSessionId = "daily-edge-session";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(),
},
});
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("expires sessions when idle timeout wins over daily reset", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S2";
const existingSessionId = "idle-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("uses per-type overrides for thread sessions", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const existingSessionId = "thread-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4 },
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("detects thread sessions without thread key suffix", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:C1";
const existingSessionId = "thread-nosuffix";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("defaults to daily resets when only resetByType is configured", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S4";
const existingSessionId = "type-default-session";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
resetByType: { thread: { mode: "idle", idleMinutes: 60 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("keeps legacy idleMinutes behavior without reset config", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S3";
const existingSessionId = "legacy-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
idleMinutes: 240,
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -6,14 +6,11 @@ import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGERS,
deriveSessionMetaPatch,
evaluateSessionFreshness,
type GroupKeyResolution,
loadSessionStore,
resolveThreadFlag,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveGroupSessionKey,
resolveSessionFilePath,
resolveSessionKey,
@@ -108,6 +105,7 @@ export async function initSessionState(params: {
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
@@ -172,19 +170,8 @@ export async function initSessionState(params: {
sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
const now = Date.now();
const isThread = resolveThreadFlag({
sessionKey,
messageThreadId: ctx.MessageThreadId,
threadLabel: ctx.ThreadLabel,
threadStarterBody: ctx.ThreadStarterBody,
parentSessionKey: ctx.ParentSessionKey,
});
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
const freshEntry = entry
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
: false;
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
if (!isNewSession && freshEntry) {
sessionId = entry.sessionId;

43
src/cli/acp-cli.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Command } from "commander";
import { serveAcpGateway } from "../acp/server.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
export function registerAcpCli(program: Command) {
program
.command("acp")
.description("Run an ACP bridge backed by the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option("--session <key>", "Default session key (e.g. agent:main:main)")
.option("--session-label <label>", "Default session label to resolve")
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--verbose, -v", "Verbose logging to stderr", false)
.addHelpText(
"after",
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.clawd.bot/cli/acp")}\n`,
)
.action((opts) => {
try {
serveAcpGateway({
gatewayUrl: opts.url as string | undefined,
gatewayToken: opts.token as string | undefined,
gatewayPassword: opts.password as string | undefined,
defaultSessionKey: opts.session as string | undefined,
defaultSessionLabel: opts.sessionLabel as string | undefined,
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
verbose: Boolean(opts.verbose),
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}

View File

@@ -163,9 +163,6 @@ export function registerPluginsCli(program: Command) {
if (plugin.toolNames.length > 0) {
lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
}
if (plugin.hookNames.length > 0) {
lines.push(`Hooks: ${plugin.hookNames.join(", ")}`);
}
if (plugin.gatewayMethods.length > 0) {
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { loadConfig } from "../../config/config.js";
import { registerPluginCliCommands } from "../../plugins/cli.js";
import { registerAcpCli } from "../acp-cli.js";
import { registerChannelsCli } from "../channels-cli.js";
import { registerCronCli } from "../cron-cli.js";
import { registerDaemonCli } from "../daemon-cli.js";
@@ -22,6 +23,7 @@ import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerLogsCli(program);

View File

@@ -9,11 +9,9 @@ import {
} from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
evaluateSessionFreshness,
DEFAULT_IDLE_MINUTES,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveSessionKey,
resolveStorePath,
type SessionEntry,
@@ -40,6 +38,8 @@ export function resolveSession(opts: {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const idleMs = idleMinutes * 60_000;
const explicitSessionKey = opts.sessionKey?.trim();
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, {
@@ -68,11 +68,7 @@ export function resolveSession(opts: {
}
}
const resetType = resolveSessionResetType({ sessionKey });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
const fresh = sessionEntry
? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }).fresh
: false;
const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs;
const sessionId =
opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
const isNewSession = !fresh && !opts.sessionId;

View File

@@ -55,7 +55,6 @@ beforeEach(() => {
killed: false,
});
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
loadClawdbotPlugins.mockReset().mockReturnValue({ plugins: [], diagnostics: [] });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -132,7 +131,6 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
});
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const loadClawdbotPlugins = vi.fn().mockReturnValue({ plugins: [], diagnostics: [] });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdbot.json",
@@ -175,8 +173,9 @@ vi.mock("../agents/skills-status.js", () => ({
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins,
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {

View File

@@ -2,7 +2,6 @@ export * from "./sessions/group.js";
export * from "./sessions/metadata.js";
export * from "./sessions/main-session.js";
export * from "./sessions/paths.js";
export * from "./sessions/reset.js";
export * from "./sessions/session-key.js";
export * from "./sessions/store.js";
export * from "./sessions/types.js";

View File

@@ -1,130 +0,0 @@
import type { SessionConfig } from "../types.base.js";
import { DEFAULT_IDLE_MINUTES } from "./types.js";
export type SessionResetMode = "daily" | "idle";
export type SessionResetType = "dm" | "group" | "thread";
export type SessionResetPolicy = {
mode: SessionResetMode;
atHour: number;
idleMinutes?: number;
};
export type SessionFreshness = {
fresh: boolean;
dailyResetAt?: number;
idleExpiresAt?: number;
};
export const DEFAULT_RESET_MODE: SessionResetMode = "daily";
export const DEFAULT_RESET_AT_HOUR = 4;
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
export function isThreadSessionKey(sessionKey?: string | null): boolean {
const normalized = (sessionKey ?? "").toLowerCase();
if (!normalized) return false;
return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker));
}
export function resolveSessionResetType(params: {
sessionKey?: string | null;
isGroup?: boolean;
isThread?: boolean;
}): SessionResetType {
if (params.isThread || isThreadSessionKey(params.sessionKey)) return "thread";
if (params.isGroup) return "group";
const normalized = (params.sessionKey ?? "").toLowerCase();
if (GROUP_SESSION_MARKERS.some((marker) => normalized.includes(marker))) return "group";
return "dm";
}
export function resolveThreadFlag(params: {
sessionKey?: string | null;
messageThreadId?: string | number | null;
threadLabel?: string | null;
threadStarterBody?: string | null;
parentSessionKey?: string | null;
}): boolean {
if (params.messageThreadId != null) return true;
if (params.threadLabel?.trim()) return true;
if (params.threadStarterBody?.trim()) return true;
if (params.parentSessionKey?.trim()) return true;
return isThreadSessionKey(params.sessionKey);
}
export function resolveDailyResetAtMs(now: number, atHour: number): number {
const normalizedAtHour = normalizeResetAtHour(atHour);
const resetAt = new Date(now);
resetAt.setHours(normalizedAtHour, 0, 0, 0);
if (now < resetAt.getTime()) {
resetAt.setDate(resetAt.getDate() - 1);
}
return resetAt.getTime();
}
export function resolveSessionResetPolicy(params: {
sessionCfg?: SessionConfig;
resetType: SessionResetType;
idleMinutesOverride?: number;
}): SessionResetPolicy {
const sessionCfg = params.sessionCfg;
const baseReset = sessionCfg?.reset;
const typeReset = sessionCfg?.resetByType?.[params.resetType];
const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType);
const legacyIdleMinutes = sessionCfg?.idleMinutes;
const mode =
typeReset?.mode ??
baseReset?.mode ??
(!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE);
const atHour = normalizeResetAtHour(typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR);
const idleMinutesRaw =
params.idleMinutesOverride ??
typeReset?.idleMinutes ??
baseReset?.idleMinutes ??
legacyIdleMinutes;
let idleMinutes: number | undefined;
if (idleMinutesRaw != null) {
const normalized = Math.floor(idleMinutesRaw);
if (Number.isFinite(normalized)) {
idleMinutes = Math.max(normalized, 1);
}
} else if (mode === "idle") {
idleMinutes = DEFAULT_IDLE_MINUTES;
}
return { mode, atHour, idleMinutes };
}
export function evaluateSessionFreshness(params: {
updatedAt: number;
now: number;
policy: SessionResetPolicy;
}): SessionFreshness {
const dailyResetAt =
params.policy.mode === "daily"
? resolveDailyResetAtMs(params.now, params.policy.atHour)
: undefined;
const idleExpiresAt =
params.policy.idleMinutes != null
? params.updatedAt + params.policy.idleMinutes * 60_000
: undefined;
const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt;
const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt;
return {
fresh: !(staleDaily || staleIdle),
dailyResetAt,
idleExpiresAt,
};
}
function normalizeResetAtHour(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RESET_AT_HOUR;
const normalized = Math.floor(value);
if (!Number.isFinite(normalized)) return DEFAULT_RESET_AT_HOUR;
if (normalized < 0) return 0;
if (normalized > 23) return 23;
return normalized;
}

View File

@@ -42,10 +42,6 @@ export type SessionEntry = {
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
execHost?: string;
execSecurity?: string;
execAsk?: string;
execNode?: string;
responseUsage?: "on" | "off" | "tokens" | "full";
providerOverride?: string;
modelOverride?: string;

View File

@@ -55,20 +55,6 @@ export type SessionSendPolicyConfig = {
rules?: SessionSendPolicyRule[];
};
export type SessionResetMode = "daily" | "idle";
export type SessionResetConfig = {
mode?: SessionResetMode;
/** Local hour (0-23) for the daily reset boundary. */
atHour?: number;
/** Sliding idle window (minutes). When set with daily mode, whichever expires first wins. */
idleMinutes?: number;
};
export type SessionResetByTypeConfig = {
dm?: SessionResetConfig;
group?: SessionResetConfig;
thread?: SessionResetConfig;
};
export type SessionConfig = {
scope?: SessionScope;
/** DM session scoping (default: "main"). */
@@ -78,8 +64,6 @@ export type SessionConfig = {
resetTriggers?: string[];
idleMinutes?: number;
heartbeatIdleMinutes?: number;
reset?: SessionResetConfig;
resetByType?: SessionResetByTypeConfig;
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;

View File

@@ -183,24 +183,6 @@ export const AgentToolsSchema = z
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
exec: z
.object({
host: z.enum(["sandbox", "gateway", "node"]).optional(),
security: z.enum(["deny", "allowlist", "full"]).optional(),
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
notifyOnExit: z.boolean().optional(),
applyPatch: z
.object({
enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,

View File

@@ -7,12 +7,6 @@ import {
QueueSchema,
} from "./zod-schema.core.js";
const SessionResetConfigSchema = z.object({
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
atHour: z.number().int().min(0).max(23).optional(),
idleMinutes: z.number().int().positive().optional(),
});
export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -23,14 +17,6 @@ export const SessionSchema = z
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
reset: SessionResetConfigSchema.optional(),
resetByType: z
.object({
dm: SessionResetConfigSchema.optional(),
group: SessionResetConfigSchema.optional(),
thread: SessionResetConfigSchema.optional(),
})
.optional(),
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z

View File

@@ -1,7 +1,5 @@
import { describe, expect, it } from "vitest";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
@@ -264,121 +262,6 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
} finally {
await server.close({ reason: "test done" });
}
});
it("does not include history markers for single message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("treats developer role same as system role", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes tool output when it is the latest message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
} finally {
await server.close({ reason: "test done" });
}
});
it("returns a non-streaming OpenAI chat.completion response", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
@@ -18,7 +17,6 @@ type OpenAiHttpOptions = {
type OpenAiChatMessage = {
role?: unknown;
content?: unknown;
name?: unknown;
};
type OpenAiChatCompletionRequest = {
@@ -87,69 +85,24 @@ function buildAgentPrompt(messagesUnknown: unknown): {
const messages = asMessages(messagesUnknown);
const systemParts: string[] = [];
const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> =
[];
let lastUser = "";
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const role = typeof msg.role === "string" ? msg.role.trim() : "";
const content = extractTextContent(msg.content).trim();
if (!role || !content) continue;
if (role === "system" || role === "developer") {
if (role === "system") {
systemParts.push(content);
continue;
}
const normalizedRole = role === "function" ? "tool" : role;
if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") {
continue;
}
const name = typeof msg.name === "string" ? msg.name.trim() : "";
const sender =
normalizedRole === "assistant"
? "Assistant"
: normalizedRole === "user"
? "User"
: name
? `Tool:${name}`
: "Tool";
conversationEntries.push({
role: normalizedRole,
entry: { sender, body: content },
});
}
let message = "";
if (conversationEntries.length > 0) {
let currentIndex = -1;
for (let i = conversationEntries.length - 1; i >= 0; i -= 1) {
const entryRole = conversationEntries[i]?.role;
if (entryRole === "user" || entryRole === "tool") {
currentIndex = i;
break;
}
}
if (currentIndex < 0) currentIndex = conversationEntries.length - 1;
const currentEntry = conversationEntries[currentIndex]?.entry;
if (currentEntry) {
const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry);
if (historyEntries.length === 0) {
message = currentEntry.body;
} else {
const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`;
message = buildHistoryContextFromEntries({
entries: [...historyEntries, currentEntry],
currentMessage: formatEntry(currentEntry),
formatEntry,
});
}
if (role === "user") {
lastUser = content;
}
}
return {
message,
message: lastUser,
extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
};
}

View File

@@ -45,10 +45,6 @@ export const SessionsPatchParamsSchema = Type.Object(
]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional(

View File

@@ -6,6 +6,7 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { isAcpSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
@@ -385,6 +386,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
runId: clientRunId,
status: "started" as const,
};
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -397,6 +399,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
ctx.deps,

View File

@@ -7,6 +7,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
@@ -299,6 +300,7 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -311,6 +313,7 @@ export const chatHandlers: GatewayRequestHandlers = {
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
context.deps,

View File

@@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},

View File

@@ -30,30 +30,6 @@ function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
}
function normalizeExecHost(raw: string): "sandbox" | "gateway" | "node" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return undefined;
}
function normalizeExecSecurity(raw: string): "deny" | "allowlist" | "full" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return undefined;
}
function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized;
}
return undefined;
}
export async function applySessionsPatchToStore(params: {
cfg: ClawdbotConfig;
store: Record<string, SessionEntry>;
@@ -174,50 +150,6 @@ export async function applySessionsPatchToStore(params: {
}
}
if ("execHost" in patch) {
const raw = patch.execHost;
if (raw === null) {
delete next.execHost;
} else if (raw !== undefined) {
const normalized = normalizeExecHost(String(raw));
if (!normalized) return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
next.execHost = normalized;
}
}
if ("execSecurity" in patch) {
const raw = patch.execSecurity;
if (raw === null) {
delete next.execSecurity;
} else if (raw !== undefined) {
const normalized = normalizeExecSecurity(String(raw));
if (!normalized) return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")');
next.execSecurity = normalized;
}
}
if ("execAsk" in patch) {
const raw = patch.execAsk;
if (raw === null) {
delete next.execAsk;
} else if (raw !== undefined) {
const normalized = normalizeExecAsk(String(raw));
if (!normalized) return invalid('invalid execAsk (use "off"|"on-miss"|"always")');
next.execAsk = normalized;
}
}
if ("execNode" in patch) {
const raw = patch.execNode;
if (raw === null) {
delete next.execNode;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) return invalid("invalid execNode: empty");
next.execNode = trimmed;
}
}
if ("model" in patch) {
const raw = patch.model;
if (raw === null) {

View File

@@ -39,7 +39,6 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap`
**What it does**: Overrides the injected SOUL content before the system prompt is built.
**Output**: No files written; swaps happen in-memory only.
**Docs**: https://docs.clawd.bot/hooks/soul-evil
**Enable**:

View File

@@ -1,7 +1,7 @@
---
name: soul-evil
description: "Swap SOUL.md with SOUL_EVIL.md during a purge window or by random chance"
homepage: https://docs.clawd.bot/hooks/soul-evil
homepage: https://docs.clawd.bot/hooks#soul-evil
metadata:
{
"clawdbot":

View File

@@ -1,11 +0,0 @@
# SOUL Evil Hook
Small persona swap hook for Clawdbot.
Docs: https://docs.clawd.bot/hooks/soul-evil
## Setup
1) `clawdbot hooks enable soul-evil`
2) Create `SOUL_EVIL.md` next to `SOUL.md` in your agent workspace
3) Configure `hooks.internal.entries.soul-evil` (see docs)

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -6,16 +8,11 @@ import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { AgentBootstrapHookContext } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
describe("soul-evil hook", () => {
it("skips subagent sessions", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
await writeWorkspaceFile({
dir: tempDir,
name: "SOUL_EVIL.md",
content: "chaotic",
});
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
await fs.writeFile(path.join(tempDir, "SOUL_EVIL.md"), "chaotic", "utf-8");
const cfg: ClawdbotConfig = {
hooks: {

View File

@@ -1,23 +1,42 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveHookConfig } from "../../config.js";
import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js";
import { applySoulEvilOverride, resolveSoulEvilConfigFromHook } from "../../soul-evil.js";
import type { AgentBootstrapHookContext, HookHandler } from "../../hooks.js";
import { applySoulEvilOverride, type SoulEvilConfig } from "../../soul-evil.js";
const HOOK_KEY = "soul-evil";
const soulEvilHook: HookHandler = async (event) => {
if (!isAgentBootstrapEvent(event)) return;
function resolveSoulEvilConfig(entry: Record<string, unknown> | undefined): SoulEvilConfig | null {
if (!entry) return null;
const file = typeof entry.file === "string" ? entry.file : undefined;
const chance = typeof entry.chance === "number" ? entry.chance : undefined;
const purge =
entry.purge && typeof entry.purge === "object"
? {
at:
typeof (entry.purge as { at?: unknown }).at === "string"
? (entry.purge as { at?: string }).at
: undefined,
duration:
typeof (entry.purge as { duration?: unknown }).duration === "string"
? (entry.purge as { duration?: string }).duration
: undefined,
}
: undefined;
if (!file && chance === undefined && !purge) return null;
return { file, chance, purge };
}
const context = event.context;
const soulEvilHook: HookHandler = async (event) => {
if (event.type !== "agent" || event.action !== "bootstrap") return;
const context = event.context as AgentBootstrapHookContext;
if (context.sessionKey && isSubagentSessionKey(context.sessionKey)) return;
const cfg = context.cfg as ClawdbotConfig | undefined;
const hookConfig = resolveHookConfig(cfg, HOOK_KEY);
if (!hookConfig || hookConfig.enabled === false) return;
const soulConfig = resolveSoulEvilConfigFromHook(hookConfig as Record<string, unknown>, {
warn: (message) => console.warn(`[soul-evil] ${message}`),
});
const soulConfig = resolveSoulEvilConfig(hookConfig as Record<string, unknown>);
if (!soulConfig) return;
const workspaceDir = context.workspaceDir;

View File

@@ -3,11 +3,9 @@ import {
clearInternalHooks,
createInternalHookEvent,
getRegisteredEventKeys,
isAgentBootstrapEvent,
registerInternalHook,
triggerInternalHook,
unregisterInternalHook,
type AgentBootstrapHookContext,
type InternalHookEvent,
} from "./internal-hooks.js";
@@ -166,22 +164,6 @@ describe("hooks", () => {
});
});
describe("isAgentBootstrapEvent", () => {
it("returns true for agent:bootstrap events with expected context", () => {
const context: AgentBootstrapHookContext = {
workspaceDir: "/tmp",
bootstrapFiles: [],
};
const event = createInternalHookEvent("agent", "bootstrap", "test-session", context);
expect(isAgentBootstrapEvent(event)).toBe(true);
});
it("returns false for non-bootstrap events", () => {
const event = createInternalHookEvent("command", "new", "test-session");
expect(isAgentBootstrapEvent(event)).toBe(false);
});
});
describe("getRegisteredEventKeys", () => {
it("should return all registered event keys", () => {
registerInternalHook("command:new", vi.fn());

View File

@@ -19,12 +19,6 @@ export type AgentBootstrapHookContext = {
agentId?: string;
};
export type AgentBootstrapHookEvent = InternalHookEvent & {
type: "agent";
action: "bootstrap";
context: AgentBootstrapHookContext;
};
export interface InternalHookEvent {
/** The type of event (command, session, agent, etc.) */
type: InternalHookEventType;
@@ -165,11 +159,3 @@ export function createInternalHookEvent(
messages: [],
};
}
export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentBootstrapHookEvent {
if (event.type !== "agent" || event.action !== "bootstrap") return false;
const context = event.context as Partial<AgentBootstrapHookContext> | null;
if (!context || typeof context !== "object") return false;
if (typeof context.workspaceDir !== "string") return false;
return Array.isArray(context.bootstrapFiles);
}

View File

@@ -1,15 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
applySoulEvilOverride,
decideSoulEvil,
DEFAULT_SOUL_EVIL_FILENAME,
resolveSoulEvilConfigFromHook,
} from "./soul-evil.js";
import { applySoulEvilOverride, decideSoulEvil, DEFAULT_SOUL_EVIL_FILENAME } from "./soul-evil.js";
import { DEFAULT_SOUL_FILENAME, type WorkspaceBootstrapFile } from "../agents/workspace.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
const makeFiles = (overrides?: Partial<WorkspaceBootstrapFile>) => [
{
@@ -91,37 +87,13 @@ describe("decideSoulEvil", () => {
expect(active.reason).toBe("purge");
expect(inactive.useEvil).toBe(false);
});
it("handles purge windows that wrap past midnight", () => {
const result = decideSoulEvil({
config: {
purge: { at: "23:55", duration: "10m" },
},
userTimezone: "UTC",
now: new Date("2026-01-02T00:02:00Z"),
});
expect(result.useEvil).toBe(true);
expect(result.reason).toBe("purge");
});
it("clamps chance above 1", () => {
const result = decideSoulEvil({
config: { chance: 2 },
random: () => 0.5,
});
expect(result.useEvil).toBe(true);
expect(result.reason).toBe("chance");
});
});
describe("applySoulEvilOverride", () => {
it("replaces SOUL content when evil is active and file exists", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_SOUL_EVIL_FILENAME,
content: "chaotic",
});
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME);
await fs.writeFile(evilPath, "chaotic", "utf-8");
const files = makeFiles({
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
@@ -140,7 +112,7 @@ describe("applySoulEvilOverride", () => {
});
it("leaves SOUL content when evil file is missing", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
const files = makeFiles({
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
});
@@ -157,64 +129,10 @@ describe("applySoulEvilOverride", () => {
expect(soul?.content).toBe("friendly");
});
it("uses custom evil filename when configured", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
await writeWorkspaceFile({
dir: tempDir,
name: "SOUL_EVIL_CUSTOM.md",
content: "chaotic",
});
const files = makeFiles({
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
});
const updated = await applySoulEvilOverride({
files,
workspaceDir: tempDir,
config: { chance: 1, file: "SOUL_EVIL_CUSTOM.md" },
userTimezone: "UTC",
random: () => 0,
});
const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME);
expect(soul?.content).toBe("chaotic");
});
it("warns and skips when evil file is empty", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_SOUL_EVIL_FILENAME,
content: " ",
});
const warnings: string[] = [];
const files = makeFiles({
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
});
const updated = await applySoulEvilOverride({
files,
workspaceDir: tempDir,
config: { chance: 1 },
userTimezone: "UTC",
random: () => 0,
log: { warn: (message) => warnings.push(message) },
});
const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME);
expect(soul?.content).toBe("friendly");
expect(warnings.some((message) => message.includes("file empty"))).toBe(true);
});
it("leaves files untouched when SOUL.md is not in bootstrap files", async () => {
const tempDir = await makeTempWorkspace("clawdbot-soul-");
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_SOUL_EVIL_FILENAME,
content: "chaotic",
});
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME);
await fs.writeFile(evilPath, "chaotic", "utf-8");
const files: WorkspaceBootstrapFile[] = [
{
@@ -236,19 +154,3 @@ describe("applySoulEvilOverride", () => {
expect(updated).toEqual(files);
});
});
describe("resolveSoulEvilConfigFromHook", () => {
it("returns null and warns when config is invalid", () => {
const warnings: string[] = [];
const result = resolveSoulEvilConfigFromHook(
{ file: 42, chance: "nope", purge: "later" },
{ warn: (message) => warnings.push(message) },
);
expect(result).toBeNull();
expect(warnings).toEqual([
"soul-evil config: file must be a string",
"soul-evil config: chance must be a number",
"soul-evil config: purge must be an object",
]);
});
});

View File

@@ -40,50 +40,6 @@ type SoulEvilLog = {
warn?: (message: string) => void;
};
export function resolveSoulEvilConfigFromHook(
entry: Record<string, unknown> | undefined,
log?: SoulEvilLog,
): SoulEvilConfig | null {
if (!entry) return null;
const file = typeof entry.file === "string" ? entry.file : undefined;
if (entry.file !== undefined && !file) {
log?.warn?.("soul-evil config: file must be a string");
}
let chance: number | undefined;
if (entry.chance !== undefined) {
if (typeof entry.chance === "number" && Number.isFinite(entry.chance)) {
chance = entry.chance;
} else {
log?.warn?.("soul-evil config: chance must be a number");
}
}
let purge: SoulEvilConfig["purge"];
if (entry.purge && typeof entry.purge === "object") {
const at =
typeof (entry.purge as { at?: unknown }).at === "string"
? (entry.purge as { at?: string }).at
: undefined;
const duration =
typeof (entry.purge as { duration?: unknown }).duration === "string"
? (entry.purge as { duration?: string }).duration
: undefined;
if ((entry.purge as { at?: unknown }).at !== undefined && !at) {
log?.warn?.("soul-evil config: purge.at must be a string");
}
if ((entry.purge as { duration?: unknown }).duration !== undefined && !duration) {
log?.warn?.("soul-evil config: purge.duration must be a string");
}
purge = { at, duration };
} else if (entry.purge !== undefined) {
log?.warn?.("soul-evil config: purge must be an object");
}
if (!file && chance === undefined && !purge) return null;
return { file, chance, purge };
}
function clampChance(value?: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));

View File

@@ -1,67 +0,0 @@
/**
* Global Plugin Hook Runner
*
* Singleton hook runner that's initialized when plugins are loaded
* and can be called from anywhere in the codebase.
*/
import { createSubsystemLogger } from "../logging.js";
import { createHookRunner, type HookRunner } from "./hooks.js";
import type { PluginRegistry } from "./registry.js";
const log = createSubsystemLogger("plugins");
let globalHookRunner: HookRunner | null = null;
let globalRegistry: PluginRegistry | null = null;
/**
* Initialize the global hook runner with a plugin registry.
* Called once when plugins are loaded during gateway startup.
*/
export function initializeGlobalHookRunner(registry: PluginRegistry): void {
globalRegistry = registry;
globalHookRunner = createHookRunner(registry, {
logger: {
debug: (msg) => log.debug(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
},
catchErrors: true,
});
const hookCount = registry.hooks.length;
if (hookCount > 0) {
log.info(`hook runner initialized with ${hookCount} registered hooks`);
}
}
/**
* Get the global hook runner.
* Returns null if plugins haven't been loaded yet.
*/
export function getGlobalHookRunner(): HookRunner | null {
return globalHookRunner;
}
/**
* Get the global plugin registry.
* Returns null if plugins haven't been loaded yet.
*/
export function getGlobalPluginRegistry(): PluginRegistry | null {
return globalRegistry;
}
/**
* Check if any hooks are registered for a given hook name.
*/
export function hasGlobalHooks(hookName: Parameters<HookRunner["hasHooks"]>[0]): boolean {
return globalHookRunner?.hasHooks(hookName) ?? false;
}
/**
* Reset the global hook runner (for testing).
*/
export function resetGlobalHookRunner(): void {
globalHookRunner = null;
globalRegistry = null;
}

View File

@@ -1,400 +0,0 @@
/**
* Plugin Hook Runner
*
* Provides utilities for executing plugin lifecycle hooks with proper
* error handling, priority ordering, and async support.
*/
import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterCompactionEvent,
PluginHookAfterToolCallEvent,
PluginHookAgentContext,
PluginHookAgentEndEvent,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeCompactionEvent,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSendingEvent,
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
PluginHookName,
PluginHookRegistration,
PluginHookSessionContext,
PluginHookSessionEndEvent,
PluginHookSessionStartEvent,
PluginHookToolContext,
} from "./types.js";
// Re-export types for consumers
export type {
PluginHookAgentContext,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookAgentEndEvent,
PluginHookBeforeCompactionEvent,
PluginHookAfterCompactionEvent,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSendingEvent,
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
PluginHookToolContext,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookAfterToolCallEvent,
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
};
export type HookRunnerLogger = {
debug?: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
export type HookRunnerOptions = {
logger?: HookRunnerLogger;
/** If true, errors in hooks will be caught and logged instead of thrown */
catchErrors?: boolean;
};
/**
* Get hooks for a specific hook name, sorted by priority (higher first).
*/
function getHooksForName<K extends PluginHookName>(
registry: PluginRegistry,
hookName: K,
): PluginHookRegistration<K>[] {
return (registry.typedHooks as PluginHookRegistration<K>[])
.filter((h) => h.hookName === hookName)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
/**
* Create a hook runner for a specific registry.
*/
export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) {
const logger = options.logger;
const catchErrors = options.catchErrors ?? true;
/**
* Run a hook that doesn't return a value (fire-and-forget style).
* All handlers are executed in parallel for performance.
*/
async function runVoidHook<K extends PluginHookName>(
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<void> {
const hooks = getHooksForName(registry, hookName);
if (hooks.length === 0) return;
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers)`);
const promises = hooks.map(async (hook) => {
try {
await (hook.handler as (event: unknown, ctx: unknown) => Promise<void>)(event, ctx);
} catch (err) {
const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`;
if (catchErrors) {
logger?.error(msg);
} else {
throw new Error(msg);
}
}
});
await Promise.all(promises);
}
/**
* Run a hook that can return a modifying result.
* Handlers are executed sequentially in priority order, and results are merged.
*/
async function runModifyingHook<K extends PluginHookName, TResult>(
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
): Promise<TResult | undefined> {
const hooks = getHooksForName(registry, hookName);
if (hooks.length === 0) return undefined;
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`);
let result: TResult | undefined;
for (const hook of hooks) {
try {
const handlerResult = await (
hook.handler as (event: unknown, ctx: unknown) => Promise<TResult>
)(event, ctx);
if (handlerResult !== undefined && handlerResult !== null) {
if (mergeResults && result !== undefined) {
result = mergeResults(result, handlerResult);
} else {
result = handlerResult;
}
}
} catch (err) {
const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`;
if (catchErrors) {
logger?.error(msg);
} else {
throw new Error(msg);
}
}
}
return result;
}
// =========================================================================
// Agent Hooks
// =========================================================================
/**
* Run before_agent_start hook.
* Allows plugins to inject context into the system prompt.
* Runs sequentially, merging systemPrompt and prependContext from all handlers.
*/
async function runBeforeAgentStart(
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeAgentStartResult | undefined> {
return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>(
"before_agent_start",
event,
ctx,
(acc, next) => ({
systemPrompt: next.systemPrompt ?? acc?.systemPrompt,
prependContext:
acc?.prependContext && next.prependContext
? `${acc.prependContext}\n\n${next.prependContext}`
: (next.prependContext ?? acc?.prependContext),
}),
);
}
/**
* Run agent_end hook.
* Allows plugins to analyze completed conversations.
* Runs in parallel (fire-and-forget).
*/
async function runAgentEnd(
event: PluginHookAgentEndEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("agent_end", event, ctx);
}
/**
* Run before_compaction hook.
*/
async function runBeforeCompaction(
event: PluginHookBeforeCompactionEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("before_compaction", event, ctx);
}
/**
* Run after_compaction hook.
*/
async function runAfterCompaction(
event: PluginHookAfterCompactionEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("after_compaction", event, ctx);
}
// =========================================================================
// Message Hooks
// =========================================================================
/**
* Run message_received hook.
* Runs in parallel (fire-and-forget).
*/
async function runMessageReceived(
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,
): Promise<void> {
return runVoidHook("message_received", event, ctx);
}
/**
* Run message_sending hook.
* Allows plugins to modify or cancel outgoing messages.
* Runs sequentially.
*/
async function runMessageSending(
event: PluginHookMessageSendingEvent,
ctx: PluginHookMessageContext,
): Promise<PluginHookMessageSendingResult | undefined> {
return runModifyingHook<"message_sending", PluginHookMessageSendingResult>(
"message_sending",
event,
ctx,
(acc, next) => ({
content: next.content ?? acc?.content,
cancel: next.cancel ?? acc?.cancel,
}),
);
}
/**
* Run message_sent hook.
* Runs in parallel (fire-and-forget).
*/
async function runMessageSent(
event: PluginHookMessageSentEvent,
ctx: PluginHookMessageContext,
): Promise<void> {
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
// Tool Hooks
// =========================================================================
/**
* Run before_tool_call hook.
* Allows plugins to modify or block tool calls.
* Runs sequentially.
*/
async function runBeforeToolCall(
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
): Promise<PluginHookBeforeToolCallResult | undefined> {
return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>(
"before_tool_call",
event,
ctx,
(acc, next) => ({
params: next.params ?? acc?.params,
block: next.block ?? acc?.block,
blockReason: next.blockReason ?? acc?.blockReason,
}),
);
}
/**
* Run after_tool_call hook.
* Runs in parallel (fire-and-forget).
*/
async function runAfterToolCall(
event: PluginHookAfterToolCallEvent,
ctx: PluginHookToolContext,
): Promise<void> {
return runVoidHook("after_tool_call", event, ctx);
}
// =========================================================================
// Session Hooks
// =========================================================================
/**
* Run session_start hook.
* Runs in parallel (fire-and-forget).
*/
async function runSessionStart(
event: PluginHookSessionStartEvent,
ctx: PluginHookSessionContext,
): Promise<void> {
return runVoidHook("session_start", event, ctx);
}
/**
* Run session_end hook.
* Runs in parallel (fire-and-forget).
*/
async function runSessionEnd(
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
): Promise<void> {
return runVoidHook("session_end", event, ctx);
}
// =========================================================================
// Gateway Hooks
// =========================================================================
/**
* Run gateway_start hook.
* Runs in parallel (fire-and-forget).
*/
async function runGatewayStart(
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,
): Promise<void> {
return runVoidHook("gateway_start", event, ctx);
}
/**
* Run gateway_stop hook.
* Runs in parallel (fire-and-forget).
*/
async function runGatewayStop(
event: PluginHookGatewayStopEvent,
ctx: PluginHookGatewayContext,
): Promise<void> {
return runVoidHook("gateway_stop", event, ctx);
}
// =========================================================================
// Utility
// =========================================================================
/**
* Check if any hooks are registered for a given hook name.
*/
function hasHooks(hookName: PluginHookName): boolean {
return registry.typedHooks.some((h) => h.hookName === hookName);
}
/**
* Get count of registered hooks for a given hook name.
*/
function getHookCount(hookName: PluginHookName): number {
return registry.typedHooks.filter((h) => h.hookName === hookName).length;
}
return {
// Agent hooks
runBeforeAgentStart,
runAgentEnd,
runBeforeCompaction,
runAfterCompaction,
// Message hooks
runMessageReceived,
runMessageSending,
runMessageSent,
// Tool hooks
runBeforeToolCall,
runAfterToolCall,
// Session hooks
runSessionStart,
runSessionEnd,
// Gateway hooks
runGatewayStart,
runGatewayStop,
// Utility
hasHooks,
getHookCount,
};
}
export type HookRunner = ReturnType<typeof createHookRunner>;

View File

@@ -99,47 +99,6 @@ describe("loadClawdbotPlugins", () => {
const memory = registry.plugins.find((entry) => entry.id === "memory-core");
expect(memory?.status).toBe("loaded");
});
it("preserves package.json metadata for bundled memory plugins", () => {
const bundledDir = makeTempDir();
const pluginDir = path.join(bundledDir, "memory-core");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@clawdbot/memory-core",
version: "1.2.3",
description: "Memory plugin package",
clawdbot: { extensions: ["./index.ts"] },
}),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.ts"),
'export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };',
"utf-8",
);
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
slots: {
memory: "memory-core",
},
},
},
});
const memory = registry.plugins.find((entry) => entry.id === "memory-core");
expect(memory?.status).toBe("loaded");
expect(memory?.origin).toBe("bundled");
expect(memory?.name).toBe("Memory (Core)");
expect(memory?.version).toBe("1.2.3");
});
it("loads plugins from config paths", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({

View File

@@ -8,7 +8,6 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
@@ -272,7 +271,6 @@ function createPluginRecord(params: {
cliCommands: [],
services: [],
httpHandlers: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,
configJsonSchema: undefined,
@@ -523,6 +521,5 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
registryCache.set(cacheKey, registry);
}
setActivePluginRegistry(registry, cacheKey);
initializeGlobalHookRunner(registry);
return registry;
}

View File

@@ -22,9 +22,6 @@ import type {
PluginLogger,
PluginOrigin,
PluginKind,
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
} from "./types.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js";
@@ -97,7 +94,6 @@ export type PluginRecord = {
cliCommands: string[];
services: string[];
httpHandlers: number;
hookCount: number;
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
@@ -107,7 +103,6 @@ export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
@@ -128,7 +123,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
@@ -352,22 +346,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord,
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number },
) => {
record.hookCount += 1;
registry.typedHooks.push({
pluginId: record.id,
hookName,
handler,
priority: opts?.priority,
source: record.source,
} as TypedPluginHookRegistration);
};
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
info: logger.info,
warn: logger.warn,
@@ -402,7 +380,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
};
};
@@ -416,7 +393,5 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod,
registerCli,
registerService,
registerHook,
registerTypedHook,
};
}

View File

@@ -200,12 +200,6 @@ export type ClawdbotPluginApi = {
registerService: (service: ClawdbotPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number },
) => void;
};
export type PluginOrigin = "bundled" | "global" | "workspace" | "config";
@@ -216,219 +210,3 @@ export type PluginDiagnostic = {
pluginId?: string;
source?: string;
};
// ============================================================================
// Plugin Hooks
// ============================================================================
export type PluginHookName =
| "before_agent_start"
| "agent_end"
| "before_compaction"
| "after_compaction"
| "message_received"
| "message_sending"
| "message_sent"
| "before_tool_call"
| "after_tool_call"
| "session_start"
| "session_end"
| "gateway_start"
| "gateway_stop";
// Agent context shared across agent hooks
export type PluginHookAgentContext = {
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
messageProvider?: string;
};
// before_agent_start hook
export type PluginHookBeforeAgentStartEvent = {
prompt: string;
messages?: unknown[];
};
export type PluginHookBeforeAgentStartResult = {
systemPrompt?: string;
prependContext?: string;
};
// agent_end hook
export type PluginHookAgentEndEvent = {
messages: unknown[];
success: boolean;
error?: string;
durationMs?: number;
};
// Compaction hooks
export type PluginHookBeforeCompactionEvent = {
messageCount: number;
tokenCount?: number;
};
export type PluginHookAfterCompactionEvent = {
messageCount: number;
tokenCount?: number;
compactedCount: number;
};
// Message context
export type PluginHookMessageContext = {
channelId: string;
accountId?: string;
conversationId?: string;
};
// message_received hook
export type PluginHookMessageReceivedEvent = {
from: string;
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
};
// message_sending hook
export type PluginHookMessageSendingEvent = {
to: string;
content: string;
metadata?: Record<string, unknown>;
};
export type PluginHookMessageSendingResult = {
content?: string;
cancel?: boolean;
};
// message_sent hook
export type PluginHookMessageSentEvent = {
to: string;
content: string;
success: boolean;
error?: string;
};
// Tool context
export type PluginHookToolContext = {
agentId?: string;
sessionKey?: string;
toolName: string;
};
// before_tool_call hook
export type PluginHookBeforeToolCallEvent = {
toolName: string;
params: Record<string, unknown>;
};
export type PluginHookBeforeToolCallResult = {
params?: Record<string, unknown>;
block?: boolean;
blockReason?: string;
};
// after_tool_call hook
export type PluginHookAfterToolCallEvent = {
toolName: string;
params: Record<string, unknown>;
result?: unknown;
error?: string;
durationMs?: number;
};
// Session context
export type PluginHookSessionContext = {
agentId?: string;
sessionId: string;
};
// session_start hook
export type PluginHookSessionStartEvent = {
sessionId: string;
resumedFrom?: string;
};
// session_end hook
export type PluginHookSessionEndEvent = {
sessionId: string;
messageCount: number;
durationMs?: number;
};
// Gateway context
export type PluginHookGatewayContext = {
port?: number;
};
// gateway_start hook
export type PluginHookGatewayStartEvent = {
port: number;
};
// gateway_stop hook
export type PluginHookGatewayStopEvent = {
reason?: string;
};
// Hook handler types mapped by hook name
export type PluginHookHandlerMap = {
before_agent_start: (
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
before_compaction: (
event: PluginHookBeforeCompactionEvent,
ctx: PluginHookAgentContext,
) => Promise<void> | void;
after_compaction: (
event: PluginHookAfterCompactionEvent,
ctx: PluginHookAgentContext,
) => Promise<void> | void;
message_received: (
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,
) => Promise<void> | void;
message_sending: (
event: PluginHookMessageSendingEvent,
ctx: PluginHookMessageContext,
) => Promise<PluginHookMessageSendingResult | void> | PluginHookMessageSendingResult | void;
message_sent: (
event: PluginHookMessageSentEvent,
ctx: PluginHookMessageContext,
) => Promise<void> | void;
before_tool_call: (
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
) => Promise<PluginHookBeforeToolCallResult | void> | PluginHookBeforeToolCallResult | void;
after_tool_call: (
event: PluginHookAfterToolCallEvent,
ctx: PluginHookToolContext,
) => Promise<void> | void;
session_start: (
event: PluginHookSessionStartEvent,
ctx: PluginHookSessionContext,
) => Promise<void> | void;
session_end: (
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
) => Promise<void> | void;
gateway_start: (
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,
) => Promise<void> | void;
gateway_stop: (
event: PluginHookGatewayStopEvent,
ctx: PluginHookGatewayContext,
) => Promise<void> | void;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {
pluginId: string;
hookName: K;
handler: PluginHookHandlerMap[K];
priority?: number;
source: string;
};

View File

@@ -1,3 +1,15 @@
import {
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
export {
isAcpSessionKey,
isSubagentSessionKey,
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default";
@@ -11,11 +23,6 @@ export function normalizeMainKey(value: string | undefined | null): string {
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
}
export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
const raw = (storeKey ?? "").trim();
if (!raw) return undefined;
@@ -70,28 +77,6 @@ export function normalizeAccountId(value: string | undefined | null): string {
);
}
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim();
if (!raw) return null;
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) return null;
if (parts[0] !== "agent") return null;
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) return null;
return { agentId, rest };
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) return false;
if (raw.toLowerCase().startsWith("subagent:")) return true;
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}
export function buildAgentMainSessionKey(params: {
agentId: string;
mainKey?: string | undefined;

Some files were not shown because too many files have changed in this diff Show More