Compare commits

..
Author SHA1 Message Date
Shakker 797cc145c3 fix(tts): read OPENAI_TTS_BASE_URL at runtime instead of module load (#3341) (thanks @hclsys) 2026-01-28 23:16:54 +00:00
chenglun.huandClaude Opus 4.5 6d9cdf9128 fix(tts): read OPENAI_TTS_BASE_URL at runtime instead of module load
Fixes #2816

Changed OPENAI_TTS_BASE_URL from a module-level constant to a function
that reads process.env at call time. This allows config.env settings to
take effect after gateway startup.

Changes:
- OPENAI_TTS_BASE_URL constant → getOpenAITtsBaseUrl() function
- isCustomOpenAIEndpoint constant → isCustomOpenAIEndpoint() function
- Updated 3 call sites to use function calls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:11:31 +00:00
Tyler Yustand0xacb a7534dc223 fix(ui): gateway URL confirmation modal (based on #2880) (#3578)
* fix: adding confirmation modal to confirm gateway url change

* refactor: added modal instead of confirm prompt

* fix(ui): reconnect after confirming gateway url (#2880) (thanks @0xacb)

---------

Co-authored-by: 0xacb <amccbaptista@gmail.com>
2026-01-28 13:32:10 -08:00
Gustavo Madeira Santana 109ac1c549 fix: banner spacing 2026-01-28 11:39:35 -05:00
AkshayandShakker 01e0d3a320 fix(cli): initialize plugins before pairing CLI registration (#3272)
The pairing CLI calls listPairingChannels() at registration time,
which requires the plugin registry to be populated. Without this,
plugin-provided channels like Matrix fail with "does not support
pairing" even though they have pairing adapters defined.

This mirrors the existing pattern used by the plugins CLI entry.

Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
2026-01-28 13:26:25 +00:00
Shakker da421b9ef7 Merge pull request #3316 from bguidolim/fix/mime-types-audio-video
fix(media): add missing MIME type mappings for audio/video files
2026-01-28 12:31:47 +00:00
Bruno Guidolim 57efd8e083 fix(media): add missing MIME type mappings for audio/video files
Add mappings for audio/x-m4a, audio/mp4, and video/quicktime to ensure
media files sent as documents are saved with proper extensions, enabling
automatic transcription/analysis tools to work correctly.

- audio/x-m4a → .m4a
- audio/mp4 → .m4a
- video/quicktime → .mov

Also adds comprehensive test coverage for extensionForMime().
2026-01-28 13:17:50 +01:00
Roopak Nijhara d93f8ffc13 fix: use fileURLToPath for Windows compatibility 2026-01-28 16:42:39 +05:30
Roopak Nijhara bffcef981d style: run pnpm format 2026-01-28 16:42:39 +05:30
Roopak NijharaandClaude Opus 4.5 39b7f9d581 feat(hooks): make session-memory message count configurable (#2681)
Adds `messages` config option to session-memory hook (default: 15).
Fixes filter order bug - now filters user/assistant messages first,
then slices to get exactly N messages. Previously sliced first which
could result in fewer messages when non-message entries were present.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:42:39 +05:30
21 changed files with 560 additions and 300 deletions

No files matched your search

+1
View File
@@ -77,6 +77,7 @@ Status: beta.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
-101
View File
@@ -1,101 +0,0 @@
---
name: bitwarden
description: Manage passwords and credentials via Bitwarden CLI (bw). Use for storing, retrieving, creating, or updating logins, credit cards, secure notes, and identities. Trigger when automating authentication, filling payment forms, or managing secrets programmatically.
---
# Bitwarden CLI
Full read/write vault access via `bw` command.
## Prerequisites
```bash
brew install bitwarden-cli
bw login <email> # one-time, prompts for master password
```
## Session Management
Bitwarden requires an unlocked session. Use the helper script:
```bash
source scripts/bw-session.sh <master_password>
# Sets BW_SESSION env var
```
Or manually:
```bash
export BW_SESSION=$(echo '<password>' | bw unlock --raw)
bw sync # always sync after unlock
```
## Common Operations
### Retrieve credentials
```bash
bw get password "Site Name"
bw get username "Site Name"
bw get item "Site Name" --pretty | jq '.login'
```
### Create login
```bash
bw get template item | jq '
.type = 1 |
.name = "Site Name" |
.login.username = "user@email.com" |
.login.password = "secret123" |
.login.uris = [{uri: "https://example.com"}]
' | bw encode | bw create item
```
### Create credit card
```bash
bw get template item | jq '
.type = 3 |
.name = "Card Name" |
.card.cardholderName = "John Doe" |
.card.brand = "Visa" |
.card.number = "4111111111111111" |
.card.expMonth = "12" |
.card.expYear = "2030" |
.card.code = "123"
' | bw encode | bw create item
```
### Get card for payment automation
```bash
bw get item "Card Name" | jq -r '.card | "\(.number) \(.expMonth)/\(.expYear) \(.code)"'
```
### List items
```bash
bw list items | jq -r '.[] | "\(.type)|\(.name)"'
# Types: 1=login, 2=note, 3=card, 4=identity
```
### Search
```bash
bw list items --search "vilaviniteca" | jq '.[0]'
```
## Item Types
| Type | Value | Use |
|------|-------|-----|
| Login | 1 | Website credentials |
| Secure Note | 2 | Freeform text |
| Card | 3 | Credit/debit cards |
| Identity | 4 | Personal info |
## References
- [templates.md](references/templates.md) — Full jq templates for all item types
- [Bitwarden CLI docs](https://bitwarden.com/help/cli/)
## Tips
1. **Always sync** after creating/editing items: `bw sync`
2. **Session expires** — re-unlock if you get auth errors
3. **Delete sensitive messages** after receiving credentials
4. **Card numbers** may not import from other managers (security restriction)
-116
View File
@@ -1,116 +0,0 @@
# Bitwarden Item Templates
jq patterns for creating vault items via CLI.
## Login (type=1)
```bash
bw get template item | jq '
.type = 1 |
.name = "Example Site" |
.notes = "Optional notes" |
.favorite = false |
.login.username = "user@example.com" |
.login.password = "secretPassword123" |
.login.totp = "otpauth://totp/..." |
.login.uris = [
{uri: "https://example.com", match: null},
{uri: "https://app.example.com", match: null}
]
' | bw encode | bw create item
```
## Credit Card (type=3)
```bash
bw get template item | jq '
.type = 3 |
.name = "Visa ending 1234" |
.notes = "Primary card" |
.card.cardholderName = "JOHN DOE" |
.card.brand = "Visa" |
.card.number = "4111111111111111" |
.card.expMonth = "12" |
.card.expYear = "2030" |
.card.code = "123"
' | bw encode | bw create item
```
**Brands:** Visa, Mastercard, Amex, Discover, Diners Club, JCB, Maestro, UnionPay, Other
## Secure Note (type=2)
```bash
bw get template item | jq '
.type = 2 |
.name = "API Keys" |
.notes = "OPENAI_KEY=sk-xxx\nANTHROPIC_KEY=sk-ant-xxx" |
.secureNote.type = 0
' | bw encode | bw create item
```
## Identity (type=4)
```bash
bw get template item | jq '
.type = 4 |
.name = "Personal Info" |
.identity.title = "Mr" |
.identity.firstName = "John" |
.identity.lastName = "Doe" |
.identity.email = "john@example.com" |
.identity.phone = "+34612345678" |
.identity.address1 = "123 Main St" |
.identity.city = "Barcelona" |
.identity.state = "Catalunya" |
.identity.postalCode = "08001" |
.identity.country = "ES"
' | bw encode | bw create item
```
## Edit Existing Item
```bash
# Get item, modify, update
bw get item <id> | jq '.login.password = "newPassword"' | bw encode | bw edit item <id>
```
## Custom Fields
```bash
bw get template item | jq '
.type = 1 |
.name = "With Custom Fields" |
.fields = [
{name: "Security Question", value: "Pet name", type: 0},
{name: "PIN", value: "1234", type: 1}
]
' | bw encode | bw create item
```
**Field types:** 0=text, 1=hidden, 2=boolean
## Retrieve Patterns
```bash
# Password only
bw get password "Site Name"
# Username only
bw get username "Site Name"
# Full login object
bw get item "Site Name" | jq '.login'
# Card number
bw get item "Card Name" | jq -r '.card.number'
# All card fields for form filling
bw get item "Card Name" | jq -r '.card | [.number, .expMonth, .expYear, .code] | @tsv'
# Search by URL
bw list items --url "example.com" | jq '.[0].login'
# List all cards
bw list items | jq '.[] | select(.type == 3) | .name'
```
-33
View File
@@ -1,33 +0,0 @@
#!/bin/bash
# Unlock Bitwarden vault and export session key
# Usage: source bw-session.sh <master_password>
# Or: source bw-session.sh (prompts for password)
set -e
if [ -n "$1" ]; then
MASTER_PW="$1"
else
read -sp "Bitwarden master password: " MASTER_PW
echo
fi
# Check if already logged in
if ! bw login --check &>/dev/null; then
echo "Not logged in. Run: bw login <email>"
return 1
fi
# Unlock and get session
export BW_SESSION=$(echo "$MASTER_PW" | bw unlock --raw 2>/dev/null)
if [ -z "$BW_SESSION" ]; then
echo "Failed to unlock vault"
return 1
fi
# Sync to get latest
bw sync &>/dev/null
echo "✓ Vault unlocked and synced"
echo "Session valid for this shell"
@@ -52,28 +52,6 @@ describe("installSessionToolResultGuard", () => {
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
});
it("ignores tool calls from aborted assistant messages", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
sm.appendMessage({
role: "assistant",
stopReason: "aborted",
content: [{ type: "toolCall", id: "call_abort", name: "read", arguments: {} }],
} as AgentMessage);
sm.appendMessage({
role: "assistant",
content: [{ type: "text", text: "next" }],
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
expect(messages.map((m) => m.role)).toEqual(["assistant", "assistant"]);
});
it("does not add synthetic toolResult when a matching one exists", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
-1
View File
@@ -7,7 +7,6 @@ import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
type ToolCall = { id: string; name?: string };
function extractAssistantToolCalls(msg: Extract<AgentMessage, { role: "assistant" }>): ToolCall[] {
if (msg.stopReason === "aborted") return [];
const content = msg.content;
if (!Array.isArray(content)) return [];
-1
View File
@@ -8,7 +8,6 @@ type ToolCallLike = {
function extractToolCallsFromAssistant(
msg: Extract<AgentMessage, { role: "assistant" }>,
): ToolCallLike[] {
if (msg.stopReason === "aborted") return [];
const content = msg.content;
if (!Array.isArray(content)) return [];
+1
View File
@@ -71,6 +71,7 @@ const LOBSTER_ASCII = [
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
+5
View File
@@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [
name: "pairing",
description: "Pairing helpers",
register: async (program) => {
// Initialize plugins before registering pairing CLI.
// The pairing CLI calls listPairingChannels() at registration time,
// which requires the plugin registry to be populated with channel plugins.
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
registerPluginCliCommands(program, await loadConfig());
const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program);
},
+2 -1
View File
@@ -69,7 +69,8 @@ export function printWizardHeader(runtime: RuntimeEnv) {
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" 🦞 FRESH DAILY 🦞 ",
" ",
].join("\n");
runtime.log(header);
}
+25 -2
View File
@@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the
When you run `/new` to start a fresh session:
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable)
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path
@@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
## Configuration
No additional configuration required. The hook automatically:
The hook supports optional configuration:
| Option | Type | Default | Description |
| ---------- | ------ | ------- | --------------------------------------------------------------- |
| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
Example configuration:
```json
{
"hooks": {
"internal": {
"entries": {
"session-memory": {
"enabled": true,
"messages": 25
}
}
}
}
}
```
The hook automatically:
- Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation
@@ -0,0 +1,379 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
/**
* Create a mock session JSONL file with various entry types
*/
function createMockSessionContent(
entries: Array<{ role: string; content: string } | { type: string }>,
): string {
return entries
.map((entry) => {
if ("role" in entry) {
return JSON.stringify({
type: "message",
message: {
role: entry.role,
content: entry.content,
},
});
}
// Non-message entry (tool call, system, etc.)
return JSON.stringify(entry);
})
.join("\n");
}
describe("session-memory hook", () => {
it("skips non-command events", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("agent", "bootstrap", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for non-command events
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("skips commands other than new", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("command", "help", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for other commands
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("creates memory file with session content on /new command", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create a mock session file with user/assistant messages
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello there" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "What is 2+2?" },
{ role: "assistant", content: "2+2 equals 4" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Memory file should be created
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
// Read the memory file and verify content
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
expect(memoryContent).toContain("user: Hello there");
expect(memoryContent).toContain("assistant: Hi! How can I help?");
expect(memoryContent).toContain("user: What is 2+2?");
expect(memoryContent).toContain("assistant: 2+2 equals 4");
});
it("filters out non-message entries (tool calls, system)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with mixed entry types
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello" },
{ type: "tool_use", tool: "search", input: "test" },
{ role: "assistant", content: "World" },
{ type: "tool_result", result: "found it" },
{ role: "user", content: "Thanks" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only user/assistant messages should be present
expect(memoryContent).toContain("user: Hello");
expect(memoryContent).toContain("assistant: World");
expect(memoryContent).toContain("user: Thanks");
// Tool entries should not appear
expect(memoryContent).not.toContain("tool_use");
expect(memoryContent).not.toContain("tool_result");
expect(memoryContent).not.toContain("search");
});
it("filters out command messages starting with /", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([
{ role: "user", content: "/help" },
{ role: "assistant", content: "Here is help info" },
{ role: "user", content: "Normal message" },
{ role: "user", content: "/new" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Command messages should be filtered out
expect(memoryContent).not.toContain("/help");
expect(memoryContent).not.toContain("/new");
// Normal messages should be present
expect(memoryContent).toContain("assistant: Here is help info");
expect(memoryContent).toContain("user: Normal message");
});
it("respects custom messages config (limits to N messages)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create 10 messages
const entries = [];
for (let i = 1; i <= 10; i++) {
entries.push({ role: "user", content: `Message ${i}` });
}
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Configure to only include last 3 messages
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only last 3 messages should be present
expect(memoryContent).not.toContain("user: Message 1\n");
expect(memoryContent).not.toContain("user: Message 7\n");
expect(memoryContent).toContain("user: Message 8");
expect(memoryContent).toContain("user: Message 9");
expect(memoryContent).toContain("user: Message 10");
});
it("filters messages before slicing (fix for #2681)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with many tool entries interspersed with messages
// This tests that we filter FIRST, then slice - not the other way around
const entries = [
{ role: "user", content: "First message" },
{ type: "tool_use", tool: "test1" },
{ type: "tool_result", result: "result1" },
{ role: "assistant", content: "Second message" },
{ type: "tool_use", tool: "test2" },
{ type: "tool_result", result: "result2" },
{ role: "user", content: "Third message" },
{ type: "tool_use", tool: "test3" },
{ type: "tool_result", result: "result3" },
{ role: "assistant", content: "Fourth message" },
];
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Request 3 messages - if we sliced first, we'd only get 1-2 messages
// because the last 3 lines include tool entries
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Should have exactly 3 user/assistant messages (the last 3)
expect(memoryContent).not.toContain("First message");
expect(memoryContent).toContain("user: Third message");
expect(memoryContent).toContain("assistant: Second message");
expect(memoryContent).toContain("assistant: Fourth message");
});
it("handles empty session files gracefully", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
// Should not throw
await handler(event);
// Memory file should still be created with metadata
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
});
it("handles session files with fewer messages than requested", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Only 2 messages but requesting 15 (default)
const sessionContent = createMockSessionContent([
{ role: "user", content: "Only message 1" },
{ role: "assistant", content: "Only message 2" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Both messages should be included
expect(memoryContent).toContain("user: Only message 1");
expect(memoryContent).toContain("assistant: Only message 2");
});
});
+22 -14
View File
@@ -8,25 +8,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import type { MoltbotConfig } from "../../../config/config.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
import { resolveHookConfig } from "../../config.js";
import type { HookHandler } from "../../hooks.js";
/**
* Read recent messages from session file for slug generation
*/
async function getRecentSessionContent(sessionFilePath: string): Promise<string | null> {
async function getRecentSessionContent(
sessionFilePath: string,
messageCount: number = 15,
): Promise<string | null> {
try {
const content = await fs.readFile(sessionFilePath, "utf-8");
const lines = content.trim().split("\n");
// Get last 15 lines (recent conversation)
const recentLines = lines.slice(-15);
// Parse JSONL and extract messages
const messages: string[] = [];
for (const line of recentLines) {
// Parse JSONL and extract user/assistant messages first
const allMessages: string[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object
@@ -39,7 +41,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
? msg.content.find((c: any) => c.type === "text")?.text
: msg.content;
if (text && !text.startsWith("/")) {
messages.push(`${role}: ${text}`);
allMessages.push(`${role}: ${text}`);
}
}
}
@@ -48,7 +50,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
}
}
return messages.join("\n");
// Then slice to get exactly messageCount messages
const recentMessages = allMessages.slice(-messageCount);
return recentMessages.join("\n");
} catch {
return null;
}
@@ -93,12 +97,19 @@ const saveSessionToMemory: HookHandler = async (event) => {
const sessionFile = currentSessionFile || undefined;
// Read message count from hook config (default: 15)
const hookConfig = resolveHookConfig(cfg, "session-memory");
const messageCount =
typeof hookConfig?.messages === "number" && hookConfig.messages > 0
? hookConfig.messages
: 15;
let slug: string | null = null;
let sessionContent: string | null = null;
if (sessionFile) {
// Get recent conversation content
sessionContent = await getRecentSessionContent(sessionFile);
sessionContent = await getRecentSessionContent(sessionFile, messageCount);
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0);
if (sessionContent && cfg) {
@@ -106,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => {
// Dynamically import the LLM slug generator (avoids module caching issues)
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
const moltbotRoot = path.resolve(
path.dirname(import.meta.url.replace("file://", "")),
"../..",
);
const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js");
const { generateSlugViaLLM } = await import(slugGenPath);
+45 -1
View File
@@ -1,7 +1,7 @@
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { detectMime, imageMimeFromFormat } from "./mime.js";
import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js";
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
const zip = new JSZip();
@@ -53,3 +53,47 @@ describe("mime detection", () => {
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
});
});
describe("extensionForMime", () => {
it("maps image MIME types to extensions", () => {
expect(extensionForMime("image/jpeg")).toBe(".jpg");
expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/webp")).toBe(".webp");
expect(extensionForMime("image/gif")).toBe(".gif");
expect(extensionForMime("image/heic")).toBe(".heic");
});
it("maps audio MIME types to extensions", () => {
expect(extensionForMime("audio/mpeg")).toBe(".mp3");
expect(extensionForMime("audio/ogg")).toBe(".ogg");
expect(extensionForMime("audio/x-m4a")).toBe(".m4a");
expect(extensionForMime("audio/mp4")).toBe(".m4a");
});
it("maps video MIME types to extensions", () => {
expect(extensionForMime("video/mp4")).toBe(".mp4");
expect(extensionForMime("video/quicktime")).toBe(".mov");
});
it("maps document MIME types to extensions", () => {
expect(extensionForMime("application/pdf")).toBe(".pdf");
expect(extensionForMime("text/plain")).toBe(".txt");
expect(extensionForMime("text/markdown")).toBe(".md");
});
it("handles case insensitivity", () => {
expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg");
expect(extensionForMime("Audio/X-M4A")).toBe(".m4a");
expect(extensionForMime("Video/QuickTime")).toBe(".mov");
});
it("returns undefined for unknown MIME types", () => {
expect(extensionForMime("video/unknown")).toBeUndefined();
expect(extensionForMime("application/x-custom")).toBeUndefined();
});
it("returns undefined for null or undefined input", () => {
expect(extensionForMime(null)).toBeUndefined();
expect(extensionForMime(undefined)).toBeUndefined();
});
});
+3
View File
@@ -13,7 +13,10 @@ const EXT_BY_MIME: Record<string, string> = {
"image/gif": ".gif",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
"audio/x-m4a": ".m4a",
"audio/mp4": ".m4a",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"application/pdf": ".pdf",
"application/json": ".json",
"application/zip": ".zip",
+15 -7
View File
@@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con
* Custom OpenAI-compatible TTS endpoint.
* When set, model/voice validation is relaxed to allow non-OpenAI models.
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
*
* Note: Read at runtime (not module load) to support config.env loading.
*/
const OPENAI_TTS_BASE_URL = (
process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
).replace(/\/+$/, "");
const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
function getOpenAITtsBaseUrl(): string {
return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace(
/\/+$/,
"",
);
}
function isCustomOpenAIEndpoint(): boolean {
return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1";
}
export const OPENAI_TTS_VOICES = [
"alloy",
"ash",
@@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
function isValidOpenAIModel(model: string): boolean {
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
if (isCustomOpenAIEndpoint) return true;
if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
}
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
if (isCustomOpenAIEndpoint) return true;
if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
}
@@ -1011,7 +1019,7 @@ async function openaiTTS(params: {
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
+2
View File
@@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation";
import {
approveDevicePairing,
loadDevices,
@@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) {
: nothing}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
</div>
`;
}
+2 -1
View File
@@ -33,6 +33,7 @@ type SettingsHost = {
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
applySettings(host, { ...host.settings, gatewayUrl });
host.pendingGatewayUrl = gatewayUrl;
}
params.delete("gatewayUrl");
shouldCleanUrl = true;
+3
View File
@@ -73,6 +73,7 @@ export type AppViewState = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
pendingGatewayUrl: string | null;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
@@ -165,6 +166,8 @@ export type AppViewState = {
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
+16
View File
@@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement {
@state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
@state() pendingGatewayUrl: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@@ -448,6 +449,21 @@ export class MoltbotApp extends LitElement {
}
}
handleGatewayUrlConfirm() {
const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) return;
this.pendingGatewayUrl = null;
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
{ ...this.settings, gatewayUrl: nextGatewayUrl },
);
this.connect();
}
handleGatewayUrlCancel() {
this.pendingGatewayUrl = null;
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {
@@ -0,0 +1,39 @@
import { html, nothing } from "lit";
import type { AppViewState } from "../app-view-state";
export function renderGatewayUrlConfirmation(state: AppViewState) {
const { pendingGatewayUrl } = state;
if (!pendingGatewayUrl) return nothing;
return html`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Change Gateway URL</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<div class="callout danger" style="margin-top: 12px;">
Only confirm if you trust this URL. Malicious URLs can compromise your system.
</div>
<div class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
Confirm
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
</button>
</div>
</div>
</div>
`;
}