Compare commits
24 Commits
acp
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6842fdf4 | ||
|
|
282dbe3167 | ||
|
|
6d749b6e2c | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 |
@@ -6,13 +6,17 @@ 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.
|
||||
- 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
|
||||
|
||||
### 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.
|
||||
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
|
||||
@@ -760,6 +760,10 @@ 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?
|
||||
@@ -773,6 +777,10 @@ 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?,
|
||||
@@ -785,6 +793,10 @@ 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
|
||||
@@ -798,6 +810,10 @@ 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"
|
||||
|
||||
@@ -29,11 +29,12 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (2/2 ready)
|
||||
Hooks (3/3 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):**
|
||||
@@ -271,4 +272,4 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
|
||||
clawdbot hooks enable soul-evil
|
||||
```
|
||||
|
||||
**See:** [soul-evil documentation](/hooks#soul-evil)
|
||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||
|
||||
@@ -54,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecyle
|
||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||
## 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).
|
||||
- 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).
|
||||
@@ -93,7 +97,18 @@ Send these as standalone messages so they register.
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 120,
|
||||
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 }
|
||||
},
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||
mainKey: "main",
|
||||
|
||||
@@ -956,6 +956,8 @@
|
||||
{
|
||||
"group": "Automation & Hooks",
|
||||
"pages": [
|
||||
"hooks",
|
||||
"hooks/soul-evil",
|
||||
"automation/auth-monitoring",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
|
||||
@@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
// Session behavior
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
idleMinutes: 60,
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 60
|
||||
},
|
||||
heartbeatIdleMinutes: 120,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
||||
|
||||
@@ -2416,7 +2416,7 @@ Notes:
|
||||
|
||||
### `session`
|
||||
|
||||
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
|
||||
Controls session scoping, reset policy, reset triggers, and where the session store is written.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 60,
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 60
|
||||
},
|
||||
resetByType: {
|
||||
thread: { mode: "daily", atHour: 4 },
|
||||
dm: { mode: "idle", idleMinutes: 240 },
|
||||
group: { mode: "idle", idleMinutes: 120 }
|
||||
},
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||
// You can override with {agentId} templating:
|
||||
@@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -2456,6 +2465,13 @@ 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,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
|
||||
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
||||
```
|
||||
|
||||
**Check 2:** Is `idleMinutes` too short?
|
||||
**Check 2:** Is the reset window too short?
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"idleMinutes": 10080 // 7 days
|
||||
"reset": {
|
||||
"mode": "daily",
|
||||
"atHour": 4,
|
||||
"idleMinutes": 10080 // 7 days
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -37,10 +37,11 @@ The hooks system allows you to:
|
||||
|
||||
### Bundled Hooks
|
||||
|
||||
Clawdbot ships with two bundled hooks that are automatically discovered:
|
||||
Clawdbot ships with three 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:
|
||||
|
||||
@@ -511,6 +512,8 @@ 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**:
|
||||
|
||||
68
docs/hooks/soul-evil.md
Normal file
68
docs/hooks/soul-evil.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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,7 +82,8 @@ 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`.
|
||||
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
|
||||
- **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.
|
||||
|
||||
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
||||
|
||||
|
||||
@@ -160,7 +160,11 @@ Example:
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
idleMinutes: 10080
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 10080
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -880,14 +880,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
||||
|
||||
### Do sessions reset automatically if I never send `/new`?
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
idleMinutes: 240
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 240
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,6 +41,16 @@ 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`, `/model`, `/queue`.
|
||||
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/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,6 +77,7 @@ 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)
|
||||
|
||||
14
extensions/memory-core/package.json
Normal file
14
extensions/memory-core/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-core",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot core memory search plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -238,6 +238,8 @@ importers:
|
||||
specifier: 3.14.5
|
||||
version: 3.14.5(typescript@5.9.3)
|
||||
|
||||
extensions/bluebubbles: {}
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
@@ -256,6 +258,12 @@ importers:
|
||||
specifier: 40.0.0
|
||||
version: 40.0.0
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/msteams:
|
||||
dependencies:
|
||||
'@microsoft/agents-hosting':
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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,
|
||||
@@ -29,7 +28,7 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
];
|
||||
});
|
||||
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
|
||||
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.some((file) => file.name === "EXTRA.md")).toBe(true);
|
||||
@@ -54,7 +53,7 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
];
|
||||
});
|
||||
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
|
||||
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
|
||||
const result = await resolveBootstrapContextForRun({ workspaceDir });
|
||||
const extra = result.contextFiles.find((file) => file.path === "EXTRA.md");
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ 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 { resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||
import { makeBootstrapWarn, 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: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
});
|
||||
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 { resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
||||
import { makeBootstrapWarn, 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: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
});
|
||||
const runAbortController = new AbortController();
|
||||
const toolsRaw = createClawdbotCodingTools({
|
||||
|
||||
@@ -218,6 +218,7 @@ 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 { resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
import { resolveModelAuthMode } from "../../model-auth.js";
|
||||
import {
|
||||
isCloudCodeAssistFormatError,
|
||||
@@ -64,7 +64,7 @@ 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 { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
|
||||
import { mapThinkingLevel } from "../utils.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
|
||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
||||
@@ -126,14 +126,14 @@ export async function runEmbeddedAttempt(
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
});
|
||||
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
|
||||
const toolsRaw = createClawdbotCodingTools({
|
||||
exec: {
|
||||
...resolveExecToolDefaults(params.config),
|
||||
...params.execOverrides,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
|
||||
@@ -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 } from "../../bash-tools.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||
import type { SkillSnapshot } from "../../skills.js";
|
||||
|
||||
@@ -34,6 +34,7 @@ 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 } from "../../bash-tools.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } 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,6 +39,7 @@ export type EmbeddedRunAttemptParams = {
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
timeoutMs: number;
|
||||
runId: string;
|
||||
|
||||
@@ -108,13 +108,19 @@ 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,
|
||||
},
|
||||
});
|
||||
@@ -122,6 +128,7 @@ export function handleMessageUpdate(
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
delta: deltaText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,4 +146,42 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("Agent-specific sandbox config", () => {
|
||||
|
||||
it(
|
||||
"should use global sandbox config when no agent-specific config exists",
|
||||
{ timeout: 15_000 },
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const { resolveSandboxContext } = await import("./sandbox.js");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("sandbox config merges", () => {
|
||||
it("resolves sandbox scope deterministically", { timeout: 15_000 }, async () => {
|
||||
it("resolves sandbox scope deterministically", { timeout: 60_000 }, async () => {
|
||||
const { resolveSandboxScope } = await import("./sandbox.js");
|
||||
|
||||
expect(resolveSandboxScope({})).toBe("agent");
|
||||
|
||||
@@ -367,6 +367,20 @@ 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,6 +147,47 @@ 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,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { extractStatusDirective } from "./reply/directives.js";
|
||||
import {
|
||||
extractElevatedDirective,
|
||||
extractExecDirective,
|
||||
extractQueueDirective,
|
||||
extractReasoningDirective,
|
||||
extractReplyToTag,
|
||||
@@ -112,6 +113,26 @@ 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);
|
||||
|
||||
@@ -97,7 +97,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("stages inbound media into the sandbox workspace", { timeout: 15_000 }, async () => {
|
||||
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
||||
await fs.mkdir(inboundDir, { recursive: true });
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,6 +226,7 @@ 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,6 +123,7 @@ 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,8 +1,13 @@
|
||||
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
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";
|
||||
@@ -23,6 +28,36 @@ 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;
|
||||
@@ -189,6 +224,42 @@ 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,
|
||||
@@ -254,6 +325,20 @@ 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;
|
||||
@@ -355,6 +440,16 @@ 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,9 +1,11 @@
|
||||
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,
|
||||
@@ -27,6 +29,20 @@ 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;
|
||||
@@ -83,10 +99,27 @@ 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(elevatedCleaned)
|
||||
: { cleaned: elevatedCleaned, hasDirective: false };
|
||||
? extractStatusDirective(execCleaned)
|
||||
: { cleaned: execCleaned, hasDirective: false };
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
@@ -124,6 +157,20 @@ export function parseInlineDirectives(
|
||||
hasElevatedDirective,
|
||||
elevatedLevel,
|
||||
rawElevatedLevel,
|
||||
hasExecDirective,
|
||||
execHost,
|
||||
execSecurity,
|
||||
execAsk,
|
||||
execNode,
|
||||
rawExecHost,
|
||||
rawExecSecurity,
|
||||
rawExecAsk,
|
||||
rawExecNode,
|
||||
hasExecOptions,
|
||||
invalidExecHost,
|
||||
invalidExecSecurity,
|
||||
invalidExecAsk,
|
||||
invalidExecNode,
|
||||
hasStatusDirective,
|
||||
hasModelDirective,
|
||||
rawModelDirective: rawModel,
|
||||
@@ -156,6 +203,7 @@ export function isDirectiveOnly(params: {
|
||||
!directives.hasVerboseDirective &&
|
||||
!directives.hasReasoningDirective &&
|
||||
!directives.hasElevatedDirective &&
|
||||
!directives.hasExecDirective &&
|
||||
!directives.hasModelDirective &&
|
||||
!directives.hasQueueDirective
|
||||
)
|
||||
|
||||
@@ -118,6 +118,24 @@ 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,3 +153,4 @@ export function extractStatusDirective(body?: string): {
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
|
||||
1
src/auto-reply/reply/exec.ts
Normal file
1
src/auto-reply/reply/exec.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
202
src/auto-reply/reply/exec/directive.ts
Normal file
202
src/auto-reply/reply/exec/directive.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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,6 +158,7 @@ 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,6 +110,20 @@ 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,
|
||||
@@ -206,6 +220,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
directives.hasVerboseDirective ||
|
||||
directives.hasReasoningDirective ||
|
||||
directives.hasElevatedDirective ||
|
||||
directives.hasExecDirective ||
|
||||
directives.hasModelDirective ||
|
||||
directives.hasQueueDirective ||
|
||||
directives.hasStatusDirective;
|
||||
|
||||
@@ -15,6 +15,20 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -21,6 +22,7 @@ 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;
|
||||
@@ -38,6 +40,7 @@ export type ReplyDirectiveContinuation = {
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
execOverrides?: ExecOverrides;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
@@ -59,6 +62,21 @@ 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 };
|
||||
@@ -190,11 +208,33 @@ 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) {
|
||||
@@ -405,6 +445,7 @@ export async function resolveReplyDirectives(params: {
|
||||
model = applyResult.model;
|
||||
contextTokens = applyResult.contextTokens;
|
||||
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
|
||||
const execOverrides = resolveExecOverrides({ directives, sessionEntry });
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
@@ -424,6 +465,7 @@ export async function resolveReplyDirectives(params: {
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
execOverrides,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -47,6 +48,7 @@ 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.";
|
||||
@@ -69,6 +71,7 @@ type RunPreparedReplyParams = {
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
execOverrides?: ExecOverrides;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
blockStreamingEnabled: boolean;
|
||||
@@ -227,6 +230,7 @@ export async function runPreparedReply(
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
execOverrides,
|
||||
abortedLastRun,
|
||||
} = params;
|
||||
let currentSystemSent = systemSent;
|
||||
@@ -430,6 +434,7 @@ export async function runPreparedReply(
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
reasoningLevel: resolvedReasoningLevel,
|
||||
elevatedLevel: resolvedElevatedLevel,
|
||||
execOverrides,
|
||||
bashElevated: {
|
||||
enabled: elevatedEnabled,
|
||||
allowed: elevatedAllowed,
|
||||
|
||||
@@ -157,6 +157,7 @@ export async function getReplyFromConfig(
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
execOverrides,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
@@ -241,6 +242,7 @@ export async function getReplyFromConfig(
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
execOverrides,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
blockStreamingEnabled,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
@@ -56,6 +57,7 @@ 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 } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
@@ -170,3 +170,241 @@ 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,11 +6,14 @@ 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,
|
||||
@@ -105,7 +108,6 @@ 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 });
|
||||
|
||||
@@ -170,8 +172,19 @@ export async function initSessionState(params: {
|
||||
sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
|
||||
const entry = sessionStore[sessionKey];
|
||||
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
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;
|
||||
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock("../runtime.js", () => ({
|
||||
}));
|
||||
|
||||
describe("cron cli", () => {
|
||||
it("trims model and thinking on cron add", { timeout: 30_000 }, async () => {
|
||||
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
|
||||
@@ -6,6 +6,12 @@ import { setVerbose } from "../globals.js";
|
||||
import { withProgress, withProgressTotals } from "./progress.js";
|
||||
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
||||
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
||||
import {
|
||||
resolveMemoryCacheState,
|
||||
resolveMemoryFtsState,
|
||||
resolveMemoryVectorState,
|
||||
type Tone,
|
||||
} from "../memory/status-format.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
@@ -131,6 +137,8 @@ export function registerMemoryCli(program: Command) {
|
||||
const warn = (text: string) => colorize(rich, theme.warn, text);
|
||||
const accent = (text: string) => colorize(rich, theme.accent, text);
|
||||
const label = (text: string) => muted(`${text}:`);
|
||||
const colorForTone = (tone: Tone) =>
|
||||
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
|
||||
const lines = [
|
||||
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||
@@ -164,18 +172,9 @@ export function registerMemoryCli(program: Command) {
|
||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||
}
|
||||
if (status.vector) {
|
||||
const vectorState = status.vector.enabled
|
||||
? status.vector.available
|
||||
? "ready"
|
||||
: "unavailable"
|
||||
: "disabled";
|
||||
const vectorColor =
|
||||
vectorState === "ready"
|
||||
? theme.success
|
||||
: vectorState === "unavailable"
|
||||
? theme.warn
|
||||
: theme.muted;
|
||||
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
|
||||
const vectorState = resolveMemoryVectorState(status.vector);
|
||||
const vectorColor = colorForTone(vectorState.tone);
|
||||
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
|
||||
if (status.vector.dims) {
|
||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||
}
|
||||
@@ -187,31 +186,22 @@ export function registerMemoryCli(program: Command) {
|
||||
}
|
||||
}
|
||||
if (status.fts) {
|
||||
const ftsState = status.fts.enabled
|
||||
? status.fts.available
|
||||
? "ready"
|
||||
: "unavailable"
|
||||
: "disabled";
|
||||
const ftsColor =
|
||||
ftsState === "ready"
|
||||
? theme.success
|
||||
: ftsState === "unavailable"
|
||||
? theme.warn
|
||||
: theme.muted;
|
||||
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
|
||||
const ftsState = resolveMemoryFtsState(status.fts);
|
||||
const ftsColor = colorForTone(ftsState.tone);
|
||||
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
|
||||
if (status.fts.error) {
|
||||
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
||||
}
|
||||
}
|
||||
if (status.cache) {
|
||||
const cacheState = status.cache.enabled ? "enabled" : "disabled";
|
||||
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
|
||||
const cacheState = resolveMemoryCacheState(status.cache);
|
||||
const cacheColor = colorForTone(cacheState.tone);
|
||||
const suffix =
|
||||
status.cache.enabled && typeof status.cache.entries === "number"
|
||||
? ` (${status.cache.entries} entries)`
|
||||
: "";
|
||||
lines.push(
|
||||
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
|
||||
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
|
||||
);
|
||||
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
||||
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("../commands/models.js", async () => {
|
||||
});
|
||||
|
||||
describe("models cli", () => {
|
||||
it("registers github-copilot login command", { timeout: 15_000 }, async () => {
|
||||
it("registers github-copilot login command", { timeout: 60_000 }, async () => {
|
||||
const { Command } = await import("commander");
|
||||
const { registerModelsCli } = await import("./models-cli.js");
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ 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(", ")}`);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
evaluateSessionFreshness,
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionResetPolicy,
|
||||
resolveSessionResetType,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
@@ -38,8 +40,6 @@ 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,7 +68,11 @@ export function resolveSession(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs;
|
||||
const resetType = resolveSessionResetType({ sessionKey });
|
||||
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
||||
const fresh = sessionEntry
|
||||
? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }).fresh
|
||||
: false;
|
||||
const sessionId =
|
||||
opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
|
||||
const isNewSession = !fresh && !opts.sessionId;
|
||||
|
||||
@@ -55,6 +55,7 @@ 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."],
|
||||
@@ -131,6 +132,7 @@ 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",
|
||||
@@ -173,9 +175,8 @@ vi.mock("../agents/skills-status.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/loader.js", () => ({
|
||||
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
|
||||
loadClawdbotPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
|
||||
@@ -322,7 +322,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
}));
|
||||
|
||||
describe("doctor command", () => {
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 30_000 }, async () => {
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 60_000 }, async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
path: "/tmp/clawdbot.json",
|
||||
exists: true,
|
||||
@@ -366,7 +366,7 @@ describe("doctor command", () => {
|
||||
expect(written.routing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates legacy gateway services", { timeout: 30_000 }, async () => {
|
||||
it("migrates legacy gateway services", { timeout: 60_000 }, async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
path: "/tmp/clawdbot.json",
|
||||
exists: true,
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { runSecurityAudit } from "../security/audit.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import {
|
||||
resolveMemoryCacheSummary,
|
||||
resolveMemoryFtsState,
|
||||
resolveMemoryVectorState,
|
||||
type Tone,
|
||||
} from "../memory/status-format.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
@@ -250,33 +256,24 @@ export async function statusCommand(
|
||||
parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`);
|
||||
if (memory.sources?.length) parts.push(`sources ${memory.sources.join(", ")}`);
|
||||
if (memoryPlugin.slot) parts.push(`plugin ${memoryPlugin.slot}`);
|
||||
const colorByTone = (tone: Tone, text: string) =>
|
||||
tone === "ok" ? ok(text) : tone === "warn" ? warn(text) : muted(text);
|
||||
const vector = memory.vector;
|
||||
parts.push(
|
||||
vector?.enabled === false
|
||||
? muted("vector off")
|
||||
: vector?.available
|
||||
? ok("vector ready")
|
||||
: vector?.available === false
|
||||
? warn("vector unavailable")
|
||||
: muted("vector unknown"),
|
||||
);
|
||||
if (vector) {
|
||||
const state = resolveMemoryVectorState(vector);
|
||||
const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`;
|
||||
parts.push(colorByTone(state.tone, label));
|
||||
}
|
||||
const fts = memory.fts;
|
||||
if (fts) {
|
||||
parts.push(
|
||||
fts.enabled === false
|
||||
? muted("fts off")
|
||||
: fts.available
|
||||
? ok("fts ready")
|
||||
: warn("fts unavailable"),
|
||||
);
|
||||
const state = resolveMemoryFtsState(fts);
|
||||
const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`;
|
||||
parts.push(colorByTone(state.tone, label));
|
||||
}
|
||||
const cache = memory.cache;
|
||||
if (cache) {
|
||||
parts.push(
|
||||
cache.enabled
|
||||
? ok(`cache on${typeof cache.entries === "number" ? ` (${cache.entries})` : ""}`)
|
||||
: muted("cache off"),
|
||||
);
|
||||
const summary = resolveMemoryCacheSummary(cache);
|
||||
parts.push(colorByTone(summary.tone, summary.text));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
})();
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
130
src/config/sessions/reset.ts
Normal file
130
src/config/sessions/reset.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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,6 +42,10 @@ 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,6 +55,20 @@ 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"). */
|
||||
@@ -64,6 +78,8 @@ export type SessionConfig = {
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
reset?: SessionResetConfig;
|
||||
resetByType?: SessionResetByTypeConfig;
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
typingMode?: TypingMode;
|
||||
|
||||
@@ -183,6 +183,24 @@ 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,6 +7,12 @@ 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(),
|
||||
@@ -17,6 +23,14 @@ 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
|
||||
|
||||
@@ -33,7 +33,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("discord native commands", () => {
|
||||
it("streams tool results for native slash commands", { timeout: 30_000 }, async () => {
|
||||
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
|
||||
const { ChannelType } = await import("@buape/carbon");
|
||||
const { createDiscordNativeCommand } = await import("./monitor.js");
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -262,6 +264,121 @@ 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,6 +1,7 @@
|
||||
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";
|
||||
@@ -17,6 +18,7 @@ type OpenAiHttpOptions = {
|
||||
type OpenAiChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
name?: unknown;
|
||||
};
|
||||
|
||||
type OpenAiChatCompletionRequest = {
|
||||
@@ -85,24 +87,69 @@ function buildAgentPrompt(messagesUnknown: unknown): {
|
||||
const messages = asMessages(messagesUnknown);
|
||||
|
||||
const systemParts: string[] = [];
|
||||
let lastUser = "";
|
||||
const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> =
|
||||
[];
|
||||
|
||||
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") {
|
||||
if (role === "system" || role === "developer") {
|
||||
systemParts.push(content);
|
||||
continue;
|
||||
}
|
||||
if (role === "user") {
|
||||
lastUser = content;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: lastUser,
|
||||
message,
|
||||
extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ 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(
|
||||
|
||||
@@ -333,7 +333,7 @@ describe("gateway server agent", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
|
||||
test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
|
||||
}
|
||||
|
||||
describe("gateway server auth/connect", () => {
|
||||
test("closes silent handshakes after timeout", { timeout: 30_000 }, async () => {
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
vi.useRealTimers();
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
|
||||
@@ -129,7 +129,7 @@ describe("gateway server auth/connect", () => {
|
||||
|
||||
test(
|
||||
"invalid connect params surface in response and close reason",
|
||||
{ timeout: 15000 },
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
@@ -10,11 +10,27 @@ const loadConfigHelpers = async () => await import("../config/config.js");
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const { server, ws } of servers) {
|
||||
try {
|
||||
ws.close();
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
servers.length = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
describe("gateway server channels", () => {
|
||||
test("channels.status returns snapshot without probe", async () => {
|
||||
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
const { server, ws } = await startServerWithClient();
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{
|
||||
@@ -40,18 +56,12 @@ describe("gateway server channels", () => {
|
||||
expect(signal?.configured).toBe(false);
|
||||
expect(signal?.probe).toBeUndefined();
|
||||
expect(signal?.lastProbeAt).toBeNull();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("channels.logout reports no session when missing", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
|
||||
@@ -60,14 +70,10 @@ describe("gateway server channels", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.channel).toBe("whatsapp");
|
||||
expect(res.payload?.cleared).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("channels.logout clears telegram bot token from config", async () => {
|
||||
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
||||
await writeConfigFile({
|
||||
channels: {
|
||||
@@ -78,7 +84,9 @@ describe("gateway server channels", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{
|
||||
@@ -95,13 +103,5 @@ describe("gateway server channels", () => {
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.config?.channels?.telegram?.botToken).toBeUndefined();
|
||||
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
}
|
||||
|
||||
describe("gateway server chat", () => {
|
||||
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
|
||||
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -105,7 +105,7 @@ describe("gateway server chat", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
|
||||
test("chat.abort cancels an in-flight chat.send", { timeout: 60_000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -263,7 +263,7 @@ describe("gateway server chat", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
|
||||
test("chat.send treats /stop as an out-of-band abort", { timeout: 60_000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
connectOk,
|
||||
@@ -12,13 +10,26 @@ import {
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const { server, ws } of servers) {
|
||||
try {
|
||||
ws.close();
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
servers.length = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
describe("gateway config.apply", () => {
|
||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sigusr1 = vi.fn();
|
||||
process.on("SIGUSR1", sigusr1);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-1";
|
||||
@@ -40,22 +51,14 @@ describe("gateway config.apply", () => {
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sigusr1).toHaveBeenCalled();
|
||||
|
||||
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
|
||||
const raw = await fs.readFile(sentinelPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
|
||||
expect(parsed.payload?.kind).toBe("config-apply");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
process.off("SIGUSR1", sigusr1);
|
||||
vi.useRealTimers();
|
||||
const sentinel = await readRestartSentinel();
|
||||
expect(sentinel?.payload.kind).toBe("config-apply");
|
||||
});
|
||||
|
||||
it("rejects invalid raw config", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-2";
|
||||
@@ -74,8 +77,5 @@ describe("gateway config.apply", () => {
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server health/presence", () => {
|
||||
test("connect + health + presence + status succeed", { timeout: 20_000 }, async () => {
|
||||
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("gateway server misc", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("send dedupes by idempotencyKey", { timeout: 20_000 }, async () => {
|
||||
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("gateway server models + voicewake", () => {
|
||||
|
||||
test(
|
||||
"voicewake.get returns defaults and voicewake.set broadcasts",
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
const restoreHome = setTempHome(homeDir);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createClawdbotTools } from "../agents/clawdbot-tools.js";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
@@ -13,11 +13,25 @@ import {
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
const servers: Array<Awaited<ReturnType<typeof startGatewayServer>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
servers.length = 0;
|
||||
// Add small delay to ensure port is fully released by OS
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
describe("sessions_send gateway loopback", () => {
|
||||
it("returns reply when lifecycle ends before agent.wait", async () => {
|
||||
const port = await getFreePort();
|
||||
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
|
||||
|
||||
const server = await startGatewayServer(port);
|
||||
const spy = vi.mocked(agentCommand);
|
||||
@@ -63,44 +77,37 @@ describe("sessions_send gateway loopback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
servers.push(server);
|
||||
|
||||
const result = await tool.execute("call-loopback", {
|
||||
sessionKey: "main",
|
||||
message: "ping",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
reply?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.reply).toBe("pong");
|
||||
expect(details.sessionKey).toBe("main");
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
|
||||
expect(firstCall?.lane).toBe("nested");
|
||||
} finally {
|
||||
if (prevPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
|
||||
}
|
||||
await server.close();
|
||||
}
|
||||
const result = await tool.execute("call-loopback", {
|
||||
sessionKey: "main",
|
||||
message: "ping",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
reply?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.reply).toBe("pong");
|
||||
expect(details.sessionKey).toBe("main");
|
||||
|
||||
const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
|
||||
expect(firstCall?.lane).toBe("nested");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_send label lookup", () => {
|
||||
it("finds session by label and sends message", { timeout: 15_000 }, async () => {
|
||||
it("finds session by label and sends message", { timeout: 60_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
|
||||
|
||||
const server = await startGatewayServer(port);
|
||||
servers.push(server);
|
||||
const spy = vi.mocked(agentCommand);
|
||||
spy.mockImplementation(async (opts) => {
|
||||
const params = opts as {
|
||||
@@ -134,96 +141,69 @@ describe("sessions_send label lookup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// First, create a session with a label via sessions.patch
|
||||
const { callGateway } = await import("./call.js");
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: { key: "test-labeled-session", label: "my-test-worker" },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
// First, create a session with a label via sessions.patch
|
||||
const { callGateway } = await import("./call.js");
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: { key: "test-labeled-session", label: "my-test-worker" },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
// Send using label instead of sessionKey
|
||||
const result = await tool.execute("call-by-label", {
|
||||
label: "my-test-worker",
|
||||
message: "hello labeled session",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
reply?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.reply).toBe("labeled response");
|
||||
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
|
||||
} finally {
|
||||
if (prevPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
|
||||
}
|
||||
await server.close();
|
||||
}
|
||||
// Send using label instead of sessionKey
|
||||
const result = await tool.execute("call-by-label", {
|
||||
label: "my-test-worker",
|
||||
message: "hello labeled session",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
reply?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.reply).toBe("labeled response");
|
||||
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
|
||||
});
|
||||
|
||||
it("returns error when label not found", { timeout: 15_000 }, async () => {
|
||||
it("returns error when label not found", { timeout: 60_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
|
||||
|
||||
const server = await startGatewayServer(port);
|
||||
servers.push(server);
|
||||
|
||||
try {
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const result = await tool.execute("call-missing-label", {
|
||||
label: "nonexistent-label",
|
||||
message: "hello",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("No session found with label");
|
||||
} finally {
|
||||
if (prevPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
|
||||
}
|
||||
await server.close();
|
||||
}
|
||||
const result = await tool.execute("call-missing-label", {
|
||||
label: "nonexistent-label",
|
||||
message: "hello",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("No session found with label");
|
||||
});
|
||||
|
||||
it("returns error when neither sessionKey nor label provided", { timeout: 15_000 }, async () => {
|
||||
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
|
||||
|
||||
const server = await startGatewayServer(port);
|
||||
servers.push(server);
|
||||
|
||||
try {
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const result = await tool.execute("call-no-key", {
|
||||
message: "hello",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Either sessionKey or label is required");
|
||||
} finally {
|
||||
if (prevPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
|
||||
}
|
||||
await server.close();
|
||||
}
|
||||
const result = await tool.execute("call-no-key", {
|
||||
message: "hello",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Either sessionKey or label is required");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,30 @@ 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>;
|
||||
@@ -150,6 +174,50 @@ 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,6 +39,7 @@ 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":
|
||||
|
||||
11
src/hooks/bundled/soul-evil/README.md
Normal file
11
src/hooks/bundled/soul-evil/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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,5 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -8,11 +6,16 @@ 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 fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
|
||||
await fs.writeFile(path.join(tempDir, "SOUL_EVIL.md"), "chaotic", "utf-8");
|
||||
const tempDir = await makeTempWorkspace("clawdbot-soul-");
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: "SOUL_EVIL.md",
|
||||
content: "chaotic",
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
hooks: {
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveHookConfig } from "../../config.js";
|
||||
import type { AgentBootstrapHookContext, HookHandler } from "../../hooks.js";
|
||||
import { applySoulEvilOverride, type SoulEvilConfig } from "../../soul-evil.js";
|
||||
import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js";
|
||||
import { applySoulEvilOverride, resolveSoulEvilConfigFromHook } from "../../soul-evil.js";
|
||||
|
||||
const HOOK_KEY = "soul-evil";
|
||||
|
||||
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 soulEvilHook: HookHandler = async (event) => {
|
||||
if (event.type !== "agent" || event.action !== "bootstrap") return;
|
||||
if (!isAgentBootstrapEvent(event)) return;
|
||||
|
||||
const context = event.context as AgentBootstrapHookContext;
|
||||
const context = event.context;
|
||||
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 = resolveSoulEvilConfig(hookConfig as Record<string, unknown>);
|
||||
const soulConfig = resolveSoulEvilConfigFromHook(hookConfig as Record<string, unknown>, {
|
||||
warn: (message) => console.warn(`[soul-evil] ${message}`),
|
||||
});
|
||||
if (!soulConfig) return;
|
||||
|
||||
const workspaceDir = context.workspaceDir;
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
clearInternalHooks,
|
||||
createInternalHookEvent,
|
||||
getRegisteredEventKeys,
|
||||
isAgentBootstrapEvent,
|
||||
registerInternalHook,
|
||||
triggerInternalHook,
|
||||
unregisterInternalHook,
|
||||
type AgentBootstrapHookContext,
|
||||
type InternalHookEvent,
|
||||
} from "./internal-hooks.js";
|
||||
|
||||
@@ -164,6 +166,22 @@ 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,6 +19,12 @@ 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;
|
||||
@@ -159,3 +165,11 @@ 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,11 +1,15 @@
|
||||
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 } from "./soul-evil.js";
|
||||
import {
|
||||
applySoulEvilOverride,
|
||||
decideSoulEvil,
|
||||
DEFAULT_SOUL_EVIL_FILENAME,
|
||||
resolveSoulEvilConfigFromHook,
|
||||
} 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>) => [
|
||||
{
|
||||
@@ -87,13 +91,37 @@ 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 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 tempDir = await makeTempWorkspace("clawdbot-soul-");
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_SOUL_EVIL_FILENAME,
|
||||
content: "chaotic",
|
||||
});
|
||||
|
||||
const files = makeFiles({
|
||||
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
|
||||
@@ -112,7 +140,7 @@ describe("applySoulEvilOverride", () => {
|
||||
});
|
||||
|
||||
it("leaves SOUL content when evil file is missing", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-"));
|
||||
const tempDir = await makeTempWorkspace("clawdbot-soul-");
|
||||
const files = makeFiles({
|
||||
path: path.join(tempDir, DEFAULT_SOUL_FILENAME),
|
||||
});
|
||||
@@ -129,10 +157,64 @@ 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 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 tempDir = await makeTempWorkspace("clawdbot-soul-");
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_SOUL_EVIL_FILENAME,
|
||||
content: "chaotic",
|
||||
});
|
||||
|
||||
const files: WorkspaceBootstrapFile[] = [
|
||||
{
|
||||
@@ -154,3 +236,19 @@ 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,6 +40,50 @@ 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));
|
||||
|
||||
16
src/memory/headers-fingerprint.ts
Normal file
16
src/memory/headers-fingerprint.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
function normalizeHeaderName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function fingerprintHeaderNames(headers: Record<string, string> | undefined): string[] {
|
||||
if (!headers) return [];
|
||||
const out: string[] = [];
|
||||
for (const key of Object.keys(headers)) {
|
||||
const normalized = normalizeHeaderName(key);
|
||||
if (!normalized) continue;
|
||||
out.push(normalized);
|
||||
}
|
||||
out.sort((a, b) => a.localeCompare(b));
|
||||
return out;
|
||||
}
|
||||
|
||||
55
src/memory/manager-cache-key.ts
Normal file
55
src/memory/manager-cache-key.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
|
||||
|
||||
import { hashText } from "./internal.js";
|
||||
import { fingerprintHeaderNames } from "./headers-fingerprint.js";
|
||||
|
||||
export function computeMemoryManagerCacheKey(params: {
|
||||
agentId: string;
|
||||
workspaceDir: string;
|
||||
settings: ResolvedMemorySearchConfig;
|
||||
}): string {
|
||||
const settings = params.settings;
|
||||
const fingerprint = hashText(
|
||||
JSON.stringify({
|
||||
enabled: settings.enabled,
|
||||
sources: [...settings.sources].sort((a, b) => a.localeCompare(b)),
|
||||
provider: settings.provider,
|
||||
model: settings.model,
|
||||
fallback: settings.fallback,
|
||||
local: {
|
||||
modelPath: settings.local.modelPath,
|
||||
modelCacheDir: settings.local.modelCacheDir,
|
||||
},
|
||||
remote: settings.remote
|
||||
? {
|
||||
baseUrl: settings.remote.baseUrl,
|
||||
headerNames: fingerprintHeaderNames(settings.remote.headers),
|
||||
batch: settings.remote.batch
|
||||
? {
|
||||
enabled: settings.remote.batch.enabled,
|
||||
wait: settings.remote.batch.wait,
|
||||
concurrency: settings.remote.batch.concurrency,
|
||||
pollIntervalMs: settings.remote.batch.pollIntervalMs,
|
||||
timeoutMinutes: settings.remote.batch.timeoutMinutes,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
experimental: settings.experimental,
|
||||
store: {
|
||||
driver: settings.store.driver,
|
||||
path: settings.store.path,
|
||||
vector: {
|
||||
enabled: settings.store.vector.enabled,
|
||||
extensionPath: settings.store.vector.extensionPath,
|
||||
},
|
||||
},
|
||||
chunking: settings.chunking,
|
||||
sync: settings.sync,
|
||||
query: settings.query,
|
||||
cache: settings.cache,
|
||||
}),
|
||||
);
|
||||
return `${params.agentId}:${params.workspaceDir}:${fingerprint}`;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@ import {
|
||||
runOpenAiEmbeddingBatches,
|
||||
} from "./openai-batch.js";
|
||||
import {
|
||||
buildFileEntry,
|
||||
chunkMarkdown,
|
||||
ensureDir,
|
||||
hashText,
|
||||
isMemoryPath,
|
||||
listMemoryFiles,
|
||||
type MemoryChunk,
|
||||
type MemoryFileEntry,
|
||||
normalizeRelPath,
|
||||
@@ -38,8 +36,13 @@ import {
|
||||
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
|
||||
import { searchKeyword, searchVector } from "./manager-search.js";
|
||||
import { ensureMemoryIndexSchema } from "./memory-schema.js";
|
||||
import { computeMemoryManagerCacheKey } from "./manager-cache-key.js";
|
||||
import { computeEmbeddingProviderKey } from "./provider-key.js";
|
||||
import { requireNodeSqlite } from "./sqlite.js";
|
||||
import { loadSqliteVecExtension } from "./sqlite-vec.js";
|
||||
import type { SessionFileEntry } from "./session-files.js";
|
||||
import { syncMemoryFiles } from "./sync-memory-files.js";
|
||||
import { syncSessionFiles } from "./sync-session-files.js";
|
||||
|
||||
type MemorySource = "memory" | "sessions";
|
||||
|
||||
@@ -61,15 +64,6 @@ type MemoryIndexMeta = {
|
||||
vectorDims?: number;
|
||||
};
|
||||
|
||||
type SessionFileEntry = {
|
||||
path: string;
|
||||
absPath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
hash: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MemorySyncProgressUpdate = {
|
||||
completed: number;
|
||||
total: number;
|
||||
@@ -157,7 +151,7 @@ export class MemoryIndexManager {
|
||||
const settings = resolveMemorySearchConfig(cfg, agentId);
|
||||
if (!settings) return null;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}`;
|
||||
const key = computeMemoryManagerCacheKey({ agentId, workspaceDir, settings });
|
||||
const existing = INDEX_CACHE.get(key);
|
||||
if (existing) return existing;
|
||||
const providerResult = await createEmbeddingProvider({
|
||||
@@ -200,7 +194,13 @@ export class MemoryIndexManager {
|
||||
this.openAi = params.providerResult.openAi;
|
||||
this.sources = new Set(params.settings.sources);
|
||||
this.db = this.openDatabase();
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.providerKey = computeEmbeddingProviderKey({
|
||||
providerId: this.provider.id,
|
||||
providerModel: this.provider.model,
|
||||
openAi: this.openAi
|
||||
? { baseUrl: this.openAi.baseUrl, model: this.openAi.model, headers: this.openAi.headers }
|
||||
: undefined,
|
||||
});
|
||||
this.cache = {
|
||||
enabled: params.settings.cache.enabled,
|
||||
maxEntries: params.settings.cache.maxEntries,
|
||||
@@ -714,170 +714,43 @@ export class MemoryIndexManager {
|
||||
needsFullReindex: boolean;
|
||||
progress?: MemorySyncProgressState;
|
||||
}) {
|
||||
const files = await listMemoryFiles(this.workspaceDir);
|
||||
const fileEntries = await Promise.all(
|
||||
files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
|
||||
);
|
||||
log.debug("memory sync: indexing memory files", {
|
||||
files: fileEntries.length,
|
||||
await syncMemoryFiles({
|
||||
workspaceDir: this.workspaceDir,
|
||||
db: this.db,
|
||||
needsFullReindex: params.needsFullReindex,
|
||||
batch: this.batch.enabled,
|
||||
progress: params.progress,
|
||||
batchEnabled: this.batch.enabled,
|
||||
concurrency: this.getIndexConcurrency(),
|
||||
runWithConcurrency: this.runWithConcurrency.bind(this),
|
||||
indexFile: async (entry) => await this.indexFile(entry, { source: "memory" }),
|
||||
vectorTable: VECTOR_TABLE,
|
||||
ftsTable: FTS_TABLE,
|
||||
ftsEnabled: this.fts.enabled,
|
||||
ftsAvailable: this.fts.available,
|
||||
model: this.provider.model,
|
||||
});
|
||||
const activePaths = new Set(fileEntries.map((entry) => entry.path));
|
||||
if (params.progress) {
|
||||
params.progress.total += fileEntries.length;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
label: this.batch.enabled ? "Indexing memory files (batch)..." : "Indexing memory files…",
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = fileEntries.map((entry) => async () => {
|
||||
const record = this.db
|
||||
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
|
||||
.get(entry.path, "memory") as { hash: string } | undefined;
|
||||
if (!params.needsFullReindex && record?.hash === entry.hash) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.indexFile(entry, { source: "memory" });
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.runWithConcurrency(tasks, this.getIndexConcurrency());
|
||||
|
||||
const staleRows = this.db
|
||||
.prepare(`SELECT path FROM files WHERE source = ?`)
|
||||
.all("memory") as Array<{ path: string }>;
|
||||
for (const stale of staleRows) {
|
||||
if (activePaths.has(stale.path)) continue;
|
||||
this.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "memory");
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||
)
|
||||
.run(stale.path, "memory");
|
||||
} catch {}
|
||||
this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory");
|
||||
if (this.fts.enabled && this.fts.available) {
|
||||
try {
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
||||
.run(stale.path, "memory", this.provider.model);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncSessionFiles(params: {
|
||||
needsFullReindex: boolean;
|
||||
progress?: MemorySyncProgressState;
|
||||
}) {
|
||||
const files = await this.listSessionFiles();
|
||||
const activePaths = new Set(files.map((file) => this.sessionPathForFile(file)));
|
||||
const indexAll = params.needsFullReindex || this.sessionsDirtyFiles.size === 0;
|
||||
log.debug("memory sync: indexing session files", {
|
||||
files: files.length,
|
||||
indexAll,
|
||||
dirtyFiles: this.sessionsDirtyFiles.size,
|
||||
batch: this.batch.enabled,
|
||||
await syncSessionFiles({
|
||||
agentId: this.agentId,
|
||||
db: this.db,
|
||||
needsFullReindex: params.needsFullReindex,
|
||||
progress: params.progress,
|
||||
batchEnabled: this.batch.enabled,
|
||||
concurrency: this.getIndexConcurrency(),
|
||||
runWithConcurrency: this.runWithConcurrency.bind(this),
|
||||
indexFile: async (entry) => await this.indexFile(entry, { source: "sessions", content: entry.content }),
|
||||
vectorTable: VECTOR_TABLE,
|
||||
ftsTable: FTS_TABLE,
|
||||
ftsEnabled: this.fts.enabled,
|
||||
ftsAvailable: this.fts.available,
|
||||
model: this.provider.model,
|
||||
dirtyFiles: this.sessionsDirtyFiles,
|
||||
});
|
||||
if (params.progress) {
|
||||
params.progress.total += files.length;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
label: this.batch.enabled ? "Indexing session files (batch)..." : "Indexing session files…",
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = files.map((absPath) => async () => {
|
||||
if (!indexAll && !this.sessionsDirtyFiles.has(absPath)) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = await this.buildSessionEntry(absPath);
|
||||
if (!entry) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const record = this.db
|
||||
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
|
||||
.get(entry.path, "sessions") as { hash: string } | undefined;
|
||||
if (!params.needsFullReindex && record?.hash === entry.hash) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.indexFile(entry, { source: "sessions", content: entry.content });
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.runWithConcurrency(tasks, this.getIndexConcurrency());
|
||||
|
||||
const staleRows = this.db
|
||||
.prepare(`SELECT path FROM files WHERE source = ?`)
|
||||
.all("sessions") as Array<{ path: string }>;
|
||||
for (const stale of staleRows) {
|
||||
if (activePaths.has(stale.path)) continue;
|
||||
this.db
|
||||
.prepare(`DELETE FROM files WHERE path = ? AND source = ?`)
|
||||
.run(stale.path, "sessions");
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||
)
|
||||
.run(stale.path, "sessions");
|
||||
} catch {}
|
||||
this.db
|
||||
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
||||
.run(stale.path, "sessions");
|
||||
if (this.fts.enabled && this.fts.available) {
|
||||
try {
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
||||
.run(stale.path, "sessions", this.provider.model);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createSyncProgress(
|
||||
@@ -993,95 +866,6 @@ export class MemoryIndexManager {
|
||||
.run(META_KEY, value);
|
||||
}
|
||||
|
||||
private async listSessionFiles(): Promise<string[]> {
|
||||
const dir = resolveSessionTranscriptsDirForAgent(this.agentId);
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => name.endsWith(".jsonl"))
|
||||
.map((name) => path.join(dir, name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private sessionPathForFile(absPath: string): string {
|
||||
return path.join("sessions", path.basename(absPath)).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
private normalizeSessionText(value: string): string {
|
||||
return value
|
||||
.replace(/\s*\n+\s*/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private extractSessionText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
const normalized = this.normalizeSessionText(content);
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const record = block as { type?: unknown; text?: unknown };
|
||||
if (record.type !== "text" || typeof record.text !== "string") continue;
|
||||
const normalized = this.normalizeSessionText(record.text);
|
||||
if (normalized) parts.push(normalized);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
private async buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
|
||||
try {
|
||||
const stat = await fs.stat(absPath);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
const collected: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let record: unknown;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!record ||
|
||||
typeof record !== "object" ||
|
||||
(record as { type?: unknown }).type !== "message"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const message = (record as { message?: unknown }).message as
|
||||
| { role?: unknown; content?: unknown }
|
||||
| undefined;
|
||||
if (!message || typeof message.role !== "string") continue;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const text = this.extractSessionText(message.content);
|
||||
if (!text) continue;
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
collected.push(`${label}: ${text}`);
|
||||
}
|
||||
const content = collected.join("\n");
|
||||
return {
|
||||
path: this.sessionPathForFile(absPath),
|
||||
absPath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
hash: hashText(content),
|
||||
content,
|
||||
};
|
||||
} catch (err) {
|
||||
log.debug(`Failed reading session file ${absPath}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private estimateEmbeddingTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / EMBEDDING_APPROX_CHARS_PER_TOKEN);
|
||||
@@ -1233,24 +1017,6 @@ export class MemoryIndexManager {
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
private computeProviderKey(): string {
|
||||
if (this.provider.id === "openai" && this.openAi) {
|
||||
const entries = Object.entries(this.openAi.headers)
|
||||
.filter(([key]) => key.toLowerCase() !== "authorization")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => [key, value]);
|
||||
return hashText(
|
||||
JSON.stringify({
|
||||
provider: "openai",
|
||||
baseUrl: this.openAi.baseUrl,
|
||||
model: this.openAi.model,
|
||||
headers: entries,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return hashText(JSON.stringify({ provider: this.provider.id, model: this.provider.model }));
|
||||
}
|
||||
|
||||
private async embedChunksWithBatch(
|
||||
chunks: MemoryChunk[],
|
||||
entry: MemoryFileEntry | SessionFileEntry,
|
||||
|
||||
22
src/memory/provider-key.ts
Normal file
22
src/memory/provider-key.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { hashText } from "./internal.js";
|
||||
import { fingerprintHeaderNames } from "./headers-fingerprint.js";
|
||||
|
||||
export function computeEmbeddingProviderKey(params: {
|
||||
providerId: string;
|
||||
providerModel: string;
|
||||
openAi?: { baseUrl: string; model: string; headers: Record<string, string> };
|
||||
}): string {
|
||||
if (params.openAi) {
|
||||
const headerNames = fingerprintHeaderNames(params.openAi.headers);
|
||||
return hashText(
|
||||
JSON.stringify({
|
||||
provider: "openai",
|
||||
baseUrl: params.openAi.baseUrl,
|
||||
model: params.openAi.model,
|
||||
headerNames,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return hashText(JSON.stringify({ provider: params.providerId, model: params.providerModel }));
|
||||
}
|
||||
|
||||
103
src/memory/session-files.ts
Normal file
103
src/memory/session-files.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { hashText } from "./internal.js";
|
||||
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
export type SessionFileEntry = {
|
||||
path: string;
|
||||
absPath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
hash: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
|
||||
const dir = resolveSessionTranscriptsDirForAgent(agentId);
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => name.endsWith(".jsonl"))
|
||||
.map((name) => path.join(dir, name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionPathForFile(absPath: string): string {
|
||||
return path.join("sessions", path.basename(absPath)).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function normalizeSessionText(value: string): string {
|
||||
return value
|
||||
.replace(/\s*\n+\s*/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractSessionText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeSessionText(content);
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const record = block as { type?: unknown; text?: unknown };
|
||||
if (record.type !== "text" || typeof record.text !== "string") continue;
|
||||
const normalized = normalizeSessionText(record.text);
|
||||
if (normalized) parts.push(normalized);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
|
||||
try {
|
||||
const stat = await fs.stat(absPath);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
const collected: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let record: unknown;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!record || typeof record !== "object" || (record as { type?: unknown }).type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const message = (record as { message?: unknown }).message as
|
||||
| { role?: unknown; content?: unknown }
|
||||
| undefined;
|
||||
if (!message || typeof message.role !== "string") continue;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const text = extractSessionText(message.content);
|
||||
if (!text) continue;
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
collected.push(`${label}: ${text}`);
|
||||
}
|
||||
const content = collected.join("\n");
|
||||
return {
|
||||
path: sessionPathForFile(absPath),
|
||||
absPath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
hash: hashText(content),
|
||||
content,
|
||||
};
|
||||
} catch (err) {
|
||||
log.debug(`Failed reading session file ${absPath}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/memory/status-format.ts
Normal file
34
src/memory/status-format.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type Tone = "ok" | "warn" | "muted";
|
||||
|
||||
export function resolveMemoryVectorState(vector: {
|
||||
enabled: boolean;
|
||||
available?: boolean;
|
||||
}): { tone: Tone; state: "ready" | "unavailable" | "disabled" | "unknown" } {
|
||||
if (vector.enabled === false) return { tone: "muted", state: "disabled" };
|
||||
if (vector.available === true) return { tone: "ok", state: "ready" };
|
||||
if (vector.available === false) return { tone: "warn", state: "unavailable" };
|
||||
return { tone: "muted", state: "unknown" };
|
||||
}
|
||||
|
||||
export function resolveMemoryFtsState(fts: {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
}): { tone: Tone; state: "ready" | "unavailable" | "disabled" } {
|
||||
if (fts.enabled === false) return { tone: "muted", state: "disabled" };
|
||||
return fts.available ? { tone: "ok", state: "ready" } : { tone: "warn", state: "unavailable" };
|
||||
}
|
||||
|
||||
export function resolveMemoryCacheSummary(cache: {
|
||||
enabled: boolean;
|
||||
entries?: number;
|
||||
}): { tone: Tone; text: string } {
|
||||
if (!cache.enabled) return { tone: "muted", text: "cache off" };
|
||||
const suffix = typeof cache.entries === "number" ? ` (${cache.entries})` : "";
|
||||
return { tone: "ok", text: `cache on${suffix}` };
|
||||
}
|
||||
|
||||
export function resolveMemoryCacheState(cache: {
|
||||
enabled: boolean;
|
||||
}): { tone: Tone; state: "enabled" | "disabled" } {
|
||||
return cache.enabled ? { tone: "ok", state: "enabled" } : { tone: "muted", state: "disabled" };
|
||||
}
|
||||
102
src/memory/sync-memory-files.ts
Normal file
102
src/memory/sync-memory-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import {
|
||||
buildFileEntry,
|
||||
listMemoryFiles,
|
||||
type MemoryFileEntry,
|
||||
} from "./internal.js";
|
||||
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
type ProgressState = {
|
||||
completed: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
report: (update: { completed: number; total: number; label?: string }) => void;
|
||||
};
|
||||
|
||||
export async function syncMemoryFiles(params: {
|
||||
workspaceDir: string;
|
||||
db: DatabaseSync;
|
||||
needsFullReindex: boolean;
|
||||
progress?: ProgressState;
|
||||
batchEnabled: boolean;
|
||||
concurrency: number;
|
||||
runWithConcurrency: <T>(tasks: Array<() => Promise<T>>, concurrency: number) => Promise<T[]>;
|
||||
indexFile: (entry: MemoryFileEntry) => Promise<void>;
|
||||
vectorTable: string;
|
||||
ftsTable: string;
|
||||
ftsEnabled: boolean;
|
||||
ftsAvailable: boolean;
|
||||
model: string;
|
||||
}) {
|
||||
const files = await listMemoryFiles(params.workspaceDir);
|
||||
const fileEntries = await Promise.all(files.map(async (file) => buildFileEntry(file, params.workspaceDir)));
|
||||
|
||||
log.debug("memory sync: indexing memory files", {
|
||||
files: fileEntries.length,
|
||||
needsFullReindex: params.needsFullReindex,
|
||||
batch: params.batchEnabled,
|
||||
concurrency: params.concurrency,
|
||||
});
|
||||
|
||||
const activePaths = new Set(fileEntries.map((entry) => entry.path));
|
||||
if (params.progress) {
|
||||
params.progress.total += fileEntries.length;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
label: params.batchEnabled ? "Indexing memory files (batch)..." : "Indexing memory files…",
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = fileEntries.map((entry) => async () => {
|
||||
const record = params.db
|
||||
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
|
||||
.get(entry.path, "memory") as { hash: string } | undefined;
|
||||
if (!params.needsFullReindex && record?.hash === entry.hash) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await params.indexFile(entry);
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await params.runWithConcurrency(tasks, params.concurrency);
|
||||
|
||||
const staleRows = params.db
|
||||
.prepare(`SELECT path FROM files WHERE source = ?`)
|
||||
.all("memory") as Array<{ path: string }>;
|
||||
for (const stale of staleRows) {
|
||||
if (activePaths.has(stale.path)) continue;
|
||||
params.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "memory");
|
||||
try {
|
||||
params.db
|
||||
.prepare(
|
||||
`DELETE FROM ${params.vectorTable} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||
)
|
||||
.run(stale.path, "memory");
|
||||
} catch {}
|
||||
params.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory");
|
||||
if (params.ftsEnabled && params.ftsAvailable) {
|
||||
try {
|
||||
params.db
|
||||
.prepare(`DELETE FROM ${params.ftsTable} WHERE path = ? AND source = ? AND model = ?`)
|
||||
.run(stale.path, "memory", params.model);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/memory/sync-session-files.ts
Normal file
126
src/memory/sync-session-files.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import type { SessionFileEntry } from "./session-files.js";
|
||||
import { buildSessionEntry, listSessionFilesForAgent, sessionPathForFile } from "./session-files.js";
|
||||
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
type ProgressState = {
|
||||
completed: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
report: (update: { completed: number; total: number; label?: string }) => void;
|
||||
};
|
||||
|
||||
export async function syncSessionFiles(params: {
|
||||
agentId: string;
|
||||
db: DatabaseSync;
|
||||
needsFullReindex: boolean;
|
||||
progress?: ProgressState;
|
||||
batchEnabled: boolean;
|
||||
concurrency: number;
|
||||
runWithConcurrency: <T>(tasks: Array<() => Promise<T>>, concurrency: number) => Promise<T[]>;
|
||||
indexFile: (entry: SessionFileEntry) => Promise<void>;
|
||||
vectorTable: string;
|
||||
ftsTable: string;
|
||||
ftsEnabled: boolean;
|
||||
ftsAvailable: boolean;
|
||||
model: string;
|
||||
dirtyFiles: Set<string>;
|
||||
}) {
|
||||
const files = await listSessionFilesForAgent(params.agentId);
|
||||
const activePaths = new Set(files.map((file) => sessionPathForFile(file)));
|
||||
const indexAll = params.needsFullReindex || params.dirtyFiles.size === 0;
|
||||
|
||||
log.debug("memory sync: indexing session files", {
|
||||
files: files.length,
|
||||
indexAll,
|
||||
dirtyFiles: params.dirtyFiles.size,
|
||||
batch: params.batchEnabled,
|
||||
concurrency: params.concurrency,
|
||||
});
|
||||
|
||||
if (params.progress) {
|
||||
params.progress.total += files.length;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
label: params.batchEnabled ? "Indexing session files (batch)..." : "Indexing session files…",
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = files.map((absPath) => async () => {
|
||||
if (!indexAll && !params.dirtyFiles.has(absPath)) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = await buildSessionEntry(absPath);
|
||||
if (!entry) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const record = params.db
|
||||
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
|
||||
.get(entry.path, "sessions") as { hash: string } | undefined;
|
||||
if (!params.needsFullReindex && record?.hash === entry.hash) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await params.indexFile(entry);
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
completed: params.progress.completed,
|
||||
total: params.progress.total,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await params.runWithConcurrency(tasks, params.concurrency);
|
||||
|
||||
const staleRows = params.db
|
||||
.prepare(`SELECT path FROM files WHERE source = ?`)
|
||||
.all("sessions") as Array<{ path: string }>;
|
||||
for (const stale of staleRows) {
|
||||
if (activePaths.has(stale.path)) continue;
|
||||
params.db
|
||||
.prepare(`DELETE FROM files WHERE path = ? AND source = ?`)
|
||||
.run(stale.path, "sessions");
|
||||
try {
|
||||
params.db
|
||||
.prepare(
|
||||
`DELETE FROM ${params.vectorTable} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||
)
|
||||
.run(stale.path, "sessions");
|
||||
} catch {}
|
||||
params.db
|
||||
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
||||
.run(stale.path, "sessions");
|
||||
if (params.ftsEnabled && params.ftsAvailable) {
|
||||
try {
|
||||
params.db
|
||||
.prepare(`DELETE FROM ${params.ftsTable} WHERE path = ? AND source = ? AND model = ?`)
|
||||
.run(stale.path, "sessions", params.model);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,47 @@ 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({
|
||||
|
||||
17
src/test-helpers/workspace.ts
Normal file
17
src/test-helpers/workspace.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export async function makeTempWorkspace(prefix = "clawdbot-workspace-"): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export async function writeWorkspaceFile(params: {
|
||||
dir: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}): Promise<string> {
|
||||
const filePath = path.join(params.dir, params.name);
|
||||
await fs.writeFile(filePath, params.content, "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
@@ -228,7 +228,7 @@ describe("web auto-reply", () => {
|
||||
await run;
|
||||
}, 15_000);
|
||||
|
||||
it("stops after hitting max reconnect attempts", { timeout: 20000 }, async () => {
|
||||
it("stops after hitting max reconnect attempts", { timeout: 60_000 }, async () => {
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
|
||||
@@ -89,7 +89,11 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
sessionKey: sessionSnapshot.key,
|
||||
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: sessionSnapshot.fresh,
|
||||
idleMinutes: sessionSnapshot.idleMinutes,
|
||||
resetMode: sessionSnapshot.resetPolicy.mode,
|
||||
resetAtHour: sessionSnapshot.resetPolicy.atHour,
|
||||
idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null,
|
||||
dailyResetAt: sessionSnapshot.dailyResetAt ?? null,
|
||||
idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null,
|
||||
},
|
||||
"heartbeat session snapshot",
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user