Compare commits
3 Commits
feature/me
...
acp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad28889a3d | ||
|
|
3bd7615c4f | ||
|
|
41fbcc405f |
@@ -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
|
||||
|
||||
|
||||
@@ -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
194
docs.acp.md
Normal 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
143
docs/cli/acp.md
Normal 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 Zed’s 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
{
|
||||
"group": "Automation & Hooks",
|
||||
"pages": [
|
||||
"hooks",
|
||||
"hooks/soul-evil",
|
||||
"automation/auth-monitoring",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (0–5).
|
||||
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 (0–5, 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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 0–1): 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)
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -160,11 +160,7 @@ Example:
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 10080
|
||||
}
|
||||
idleMinutes: 10080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
349
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
34
src/acp/event-mapper.test.ts
Normal file
34
src/acp/event-mapper.test.ts
Normal 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
73
src/acp/event-mapper.ts
Normal 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
4
src/acp/index.ts
Normal 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
35
src/acp/meta.ts
Normal 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
149
src/acp/server.ts
Normal 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);
|
||||
}
|
||||
57
src/acp/session-mapper.test.ts
Normal file
57
src/acp/session-mapper.test.ts
Normal 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
95
src/acp/session-mapper.ts
Normal 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
26
src/acp/session.test.ts
Normal 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
93
src/acp/session.ts
Normal 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
420
src/acp/translator.ts
Normal 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
30
src/acp/types.ts
Normal 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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,4 +153,3 @@ export function extractStatusDirective(body?: string): {
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
43
src/cli/acp-cli.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user