Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff581a1ec4 | ||
|
|
eb0c48631f | ||
|
|
f362a76e38 | ||
|
|
4289a2b255 | ||
|
|
8ad99cbc0f |
No files matched your search
@@ -72,6 +72,7 @@ Status: beta.
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
### Fixes
|
||||
- Hooks: make session-memory message count configurable and filter messages before slicing. (#2732) Thanks @rnijhara.
|
||||
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
|
||||
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
|
||||
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user