Compare commits

..
Author SHA1 Message Date
Peter Steinberger 172b6e3ba2 fix: harden memory plugin config (#1149) (thanks @radek-paclt) 2026-01-18 07:09:49 +00:00
Radek PacltandClaude Opus 4.5 e0b1561e16 feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure:
- before_agent_start: inject context before agent loop
- agent_end: analyze conversation after completion
- 13 hook types total (message, tool, session, gateway hooks)

Memory plugin implementation:
- LanceDB vector storage with OpenAI embeddings
- kind: "memory" to integrate with upstream slot system
- Auto-recall: injects <relevant-memories> when context found
- Auto-capture: stores preferences, decisions, entities
- Rule-based capture filtering with 0.95 similarity dedup
- Tools: memory_recall, memory_store, memory_forget
- CLI: clawdbot ltm list|search|stats

Plugin infrastructure:
- api.on() method for hook registration
- Global hook runner singleton for cross-module access
- Priority ordering and error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:59:03 +00:00
42 changed files with 2594 additions and 700 deletions

No files matched your search

+1 -1
View File
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - 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: 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. - macOS: add approvals socket UI server + node exec lifecycle events.
- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - 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. - 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 - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
@@ -16,7 +17,6 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Tools: return a companion-app-required message when node exec is requested with no paired node. - 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. - 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 ## 2026.1.18-2
+281
View File
@@ -0,0 +1,281 @@
/**
* Memory Plugin E2E Tests
*
* Tests the memory plugin functionality including:
* - Plugin registration and configuration
* - Memory storage and retrieval
* - Auto-recall via hooks
* - Auto-capture filtering
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
// Skip if no OpenAI API key
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
describeWithKey("memory plugin e2e", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory plugin registers and initializes correctly", async () => {
// Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory");
expect(memoryPlugin.name).toBe("Memory (Vector)");
expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function);
});
test("config schema parses valid config", async () => {
const { default: memoryPlugin } = await import("./index.js");
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: true,
autoRecall: true,
});
expect(config).toBeDefined();
expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY);
expect(config?.dbPath).toBe(dbPath);
});
test("config schema resolves env vars", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Set a test env var
process.env.TEST_MEMORY_API_KEY = "test-key-123";
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: "${TEST_MEMORY_API_KEY}",
},
dbPath,
});
expect(config?.embedding?.apiKey).toBe("test-key-123");
delete process.env.TEST_MEMORY_API_KEY;
});
test("config schema rejects missing apiKey", async () => {
const { default: memoryPlugin } = await import("./index.js");
expect(() => {
memoryPlugin.configSchema?.parse?.({
embedding: {},
dbPath,
});
}).toThrow("embedding.apiKey is required");
});
test("shouldCapture filters correctly", async () => {
// Test the capture filtering logic by checking the rules
const triggers = [
{ text: "I prefer dark mode", shouldMatch: true },
{ text: "Remember that my name is John", shouldMatch: true },
{ text: "My email is test@example.com", shouldMatch: true },
{ text: "Call me at +1234567890123", shouldMatch: true },
{ text: "We decided to use TypeScript", shouldMatch: true },
{ text: "I always want verbose output", shouldMatch: true },
{ text: "Just a random short message", shouldMatch: false },
{ text: "x", shouldMatch: false }, // Too short
{ text: "<relevant-memories>injected</relevant-memories>", shouldMatch: false }, // Skip injected
];
// The shouldCapture function is internal, but we can test via the capture behavior
// For now, just verify the patterns we expect to match
for (const { text, shouldMatch } of triggers) {
const hasPreference = /prefer|radši|like|love|hate|want/i.test(text);
const hasRemember = /zapamatuj|pamatuj|remember/i.test(text);
const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text);
const hasPhone = /\+\d{10,}/.test(text);
const hasDecision = /rozhodli|decided|will use|budeme/i.test(text);
const hasAlways = /always|never|important/i.test(text);
const isInjected = text.includes("<relevant-memories>");
const isTooShort = text.length < 10;
const wouldCapture =
!isTooShort &&
!isInjected &&
(hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways);
if (shouldMatch) {
expect(wouldCapture).toBe(true);
}
}
});
test("detectCategory classifies correctly", async () => {
// Test category detection patterns
const cases = [
{ text: "I prefer dark mode", expected: "preference" },
{ text: "We decided to use React", expected: "decision" },
{ text: "My email is test@example.com", expected: "entity" },
{ text: "The server is running on port 3000", expected: "fact" },
];
for (const { text, expected } of cases) {
const lower = text.toLowerCase();
let category: string;
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
category = "preference";
} else if (/rozhodli|decided|will use|budeme/i.test(lower)) {
category = "decision";
} else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
category = "entity";
} else if (/is|are|has|have|je|má|jsou/i.test(lower)) {
category = "fact";
} else {
category = "other";
}
expect(category).toBe(expected);
}
});
});
// Live tests that require OpenAI API key and actually use LanceDB
describeWithKey("memory plugin live tests", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Mock plugin API
const registeredTools: any[] = [];
const registeredClis: any[] = [];
const registeredServices: any[] = [];
const registeredHooks: Record<string, any[]> = {};
const logs: string[] = [];
const mockApi = {
id: "memory",
name: "Memory (Vector)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: (msg: string) => logs.push(`[info] ${msg}`),
warn: (msg: string) => logs.push(`[warn] ${msg}`),
error: (msg: string) => logs.push(`[error] ${msg}`),
debug: (msg: string) => logs.push(`[debug] ${msg}`),
},
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
registerCli: (registrar: any, opts: any) => {
registeredClis.push({ registrar, opts });
},
registerService: (service: any) => {
registeredServices.push(service);
},
on: (hookName: string, handler: any) => {
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
registeredHooks[hookName].push(handler);
},
resolvePath: (p: string) => p,
};
// Register plugin
await memoryPlugin.register(mockApi as any);
// Check registration
expect(registeredTools.length).toBe(3);
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget");
expect(registeredClis.length).toBe(1);
expect(registeredServices.length).toBe(1);
// Get tool functions
const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool;
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
// Test store
const storeResult = await storeTool.execute("test-call-1", {
text: "The user prefers dark mode for all applications",
importance: 0.8,
category: "preference",
});
expect(storeResult.details?.action).toBe("created");
expect(storeResult.details?.id).toBeDefined();
const storedId = storeResult.details?.id;
// Test recall
const recallResult = await recallTool.execute("test-call-2", {
query: "dark mode preference",
limit: 5,
});
expect(recallResult.details?.count).toBeGreaterThan(0);
expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode");
// Test duplicate detection
const duplicateResult = await storeTool.execute("test-call-3", {
text: "The user prefers dark mode for all applications",
});
expect(duplicateResult.details?.action).toBe("duplicate");
// Test forget
const forgetResult = await forgetTool.execute("test-call-4", {
memoryId: storedId,
});
expect(forgetResult.details?.action).toBe("deleted");
// Verify it's gone
const recallAfterForget = await recallTool.execute("test-call-5", {
query: "dark mode preference",
limit: 5,
});
expect(recallAfterForget.details?.count).toBe(0);
}, 60000); // 60s timeout for live API calls
});
+690
View File
@@ -0,0 +1,690 @@
/**
* Clawdbot Memory Plugin
*
* Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings.
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
*/
import { Type } from "@sinclair/typebox";
import * as lancedb from "@lancedb/lancedb";
import OpenAI from "openai";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
// ============================================================================
// Types
// ============================================================================
type MemoryConfig = {
embedding: {
provider: "openai";
model?: string;
apiKey: string;
};
dbPath?: string;
autoCapture?: boolean;
autoRecall?: boolean;
};
const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
type MemoryEntry = {
id: string;
text: string;
vector: number[];
importance: number;
category: MemoryCategory;
createdAt: number;
};
type MemorySearchResult = {
entry: MemoryEntry;
score: number;
};
// ============================================================================
// Config Schema
// ============================================================================
const memoryConfigSchema = {
parse(value: unknown): MemoryConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("memory config required");
}
const cfg = value as Record<string, unknown>;
// Embedding config is required
const embedding = cfg.embedding as Record<string, unknown> | undefined;
if (!embedding || typeof embedding.apiKey !== "string") {
throw new Error("embedding.apiKey is required");
}
const model =
typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small";
ensureSupportedEmbeddingModel(model);
return {
embedding: {
provider: "openai",
model,
apiKey: resolveEnvVars(embedding.apiKey),
},
dbPath:
typeof cfg.dbPath === "string"
? cfg.dbPath
: join(homedir(), ".clawdbot", "memory", "lancedb"),
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
};
},
uiHints: {
"embedding.apiKey": {
label: "OpenAI API Key",
sensitive: true,
placeholder: "sk-proj-...",
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
},
"embedding.model": {
label: "Embedding Model",
placeholder: "text-embedding-3-small",
help: "OpenAI embedding model to use",
},
dbPath: {
label: "Database Path",
placeholder: "~/.clawdbot/memory/lancedb",
advanced: true,
},
autoCapture: {
label: "Auto-Capture",
help: "Automatically capture important information from conversations",
},
autoRecall: {
label: "Auto-Recall",
help: "Automatically inject relevant memories into context",
},
},
};
const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
};
function ensureSupportedEmbeddingModel(model: string): void {
if (!EMBEDDING_DIMENSIONS[model]) {
throw new Error(`Unsupported embedding model: ${model}`);
}
}
function stringEnum<T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
function resolveEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}
// ============================================================================
// LanceDB Provider
// ============================================================================
const TABLE_NAME = "memories";
class MemoryDB {
private db: lancedb.Connection | null = null;
private table: lancedb.Table | null = null;
private initPromise: Promise<void> | null = null;
constructor(
private readonly dbPath: string,
private readonly vectorDim: number,
) {}
private async ensureInitialized(): Promise<void> {
if (this.table) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this.doInitialize();
return this.initPromise;
}
private async doInitialize(): Promise<void> {
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();
if (tables.includes(TABLE_NAME)) {
this.table = await this.db.openTable(TABLE_NAME);
} else {
this.table = await this.db.createTable(TABLE_NAME, [
{
id: "__schema__",
text: "",
vector: new Array(this.vectorDim).fill(0),
importance: 0,
category: "other",
createdAt: 0,
},
]);
await this.table.delete('id = "__schema__"');
}
}
async store(
entry: Omit<MemoryEntry, "id" | "createdAt">,
): Promise<MemoryEntry> {
await this.ensureInitialized();
const fullEntry: MemoryEntry = {
...entry,
id: randomUUID(),
createdAt: Date.now(),
};
await this.table!.add([fullEntry]);
return fullEntry;
}
async search(
vector: number[],
limit = 5,
minScore = 0.5,
): Promise<MemorySearchResult[]> {
await this.ensureInitialized();
const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
// LanceDB uses L2 distance by default; convert to similarity score
const mapped = results.map((row) => {
const distance = row._distance ?? 0;
// Use inverse for a 0-1 range: sim = 1 / (1 + d)
const score = 1 / (1 + distance);
return {
entry: {
id: row.id as string,
text: row.text as string,
vector: row.vector as number[],
importance: row.importance as number,
category: row.category as MemoryEntry["category"],
createdAt: row.createdAt as number,
},
score,
};
});
return mapped.filter((r) => r.score >= minScore);
}
async delete(id: string): Promise<boolean> {
await this.ensureInitialized();
// Validate UUID format to prevent injection
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
throw new Error(`Invalid memory ID format: ${id}`);
}
await this.table!.delete(`id = '${id}'`);
return true;
}
async count(): Promise<number> {
await this.ensureInitialized();
return this.table!.countRows();
}
}
// ============================================================================
// OpenAI Embeddings
// ============================================================================
class Embeddings {
private client: OpenAI;
constructor(
apiKey: string,
private model: string,
) {
this.client = new OpenAI({ apiKey });
}
async embed(text: string): Promise<number[]> {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
});
return response.data[0].embedding;
}
}
// ============================================================================
// Rule-based capture filter
// ============================================================================
const MEMORY_TRIGGERS = [
/zapamatuj si|pamatuj|remember/i,
/preferuji|radši|nechci|prefer/i,
/rozhodli jsme|budeme používat/i,
/\+\d{10,}/,
/[\w.-]+@[\w.-]+\.\w+/,
/můj\s+\w+\s+je|je\s+můj/i,
/my\s+\w+\s+is|is\s+my/i,
/i (like|prefer|hate|love|want|need)/i,
/always|never|important/i,
];
function shouldCapture(text: string): boolean {
if (text.length < 10 || text.length > 500) return false;
// Skip injected context from memory recall
if (text.includes("<relevant-memories>")) return false;
// Skip system-generated content
if (text.startsWith("<") && text.includes("</")) return false;
// Skip agent summary responses (contain markdown formatting)
if (text.includes("**") && text.includes("\n-")) return false;
// Skip emoji-heavy responses (likely agent output)
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
if (emojiCount > 3) return false;
return MEMORY_TRIGGERS.some((r) => r.test(text));
}
function detectCategory(text: string): MemoryCategory {
const lower = text.toLowerCase();
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision";
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower))
return "entity";
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact";
return "other";
}
// ============================================================================
// Plugin Definition
// ============================================================================
const memoryPlugin = {
id: "memory",
name: "Memory (Vector)",
description: "Long-term memory with vector search and seamless auto-recall/capture",
kind: "memory" as const,
configSchema: memoryConfigSchema,
register(api: ClawdbotPluginApi) {
const cfg = memoryConfigSchema.parse(api.pluginConfig);
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
const vectorDim = EMBEDDING_DIMENSIONS[cfg.embedding.model ?? "text-embedding-3-small"];
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
// ========================================================================
// Tools
// ========================================================================
api.registerTool(
{
name: "memory_recall",
label: "Memory Recall",
description:
"Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
}),
async execute(_toolCallId, params) {
const { query, limit = 5 } = params as { query: string; limit?: number };
const vector = await embeddings.embed(query);
const results = await db.search(vector, limit, 0.1);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: { count: 0 },
};
}
const text = results
.map(
(r, i) =>
`${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`,
)
.join("\n");
// Strip vector data for serialization (typed arrays can't be cloned)
const sanitizedResults = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
importance: r.entry.importance,
score: r.score,
}));
return {
content: [
{ type: "text", text: `Found ${results.length} memories:\n\n${text}` },
],
details: { count: results.length, memories: sanitizedResults },
};
},
},
{ name: "memory_recall" },
);
api.registerTool(
{
name: "memory_store",
label: "Memory Store",
description:
"Save important information in long-term memory. Use for preferences, facts, decisions.",
parameters: Type.Object({
text: Type.String({ description: "Information to remember" }),
importance: Type.Optional(
Type.Number({ description: "Importance 0-1 (default: 0.7)" }),
),
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
}),
async execute(_toolCallId, params) {
const {
text,
importance = 0.7,
category = "other",
} = params as {
text: string;
importance?: number;
category?: MemoryEntry["category"];
};
const vector = await embeddings.embed(text);
// Check for duplicates
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) {
return {
content: [
{ type: "text", text: `Similar memory already exists: "${existing[0].entry.text}"` },
],
details: { action: "duplicate", existingId: existing[0].entry.id, existingText: existing[0].entry.text },
};
}
const entry = await db.store({
text,
vector,
importance,
category,
});
return {
content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
details: { action: "created", id: entry.id },
};
},
},
{ name: "memory_store" },
);
api.registerTool(
{
name: "memory_forget",
label: "Memory Forget",
description: "Delete specific memories. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(Type.String({ description: "Search to find memory" })),
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
}),
async execute(_toolCallId, params) {
const { query, memoryId } = params as { query?: string; memoryId?: string };
if (memoryId) {
await db.delete(memoryId);
return {
content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
details: { action: "deleted", id: memoryId },
};
}
if (query) {
const vector = await embeddings.embed(query);
const results = await db.search(vector, 5, 0.7);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No matching memories found." }],
details: { found: 0 },
};
}
if (results.length === 1 && results[0].score > 0.9) {
await db.delete(results[0].entry.id);
return {
content: [
{ type: "text", text: `Forgotten: "${results[0].entry.text}"` },
],
details: { action: "deleted", id: results[0].entry.id },
};
}
const list = results
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
.join("\n");
// Strip vector data for serialization
const sanitizedCandidates = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
score: r.score,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
},
],
details: { action: "candidates", candidates: sanitizedCandidates },
};
}
return {
content: [{ type: "text", text: "Provide query or memoryId." }],
details: { error: "missing_param" },
};
},
},
{ name: "memory_forget" },
);
// ========================================================================
// CLI Commands
// ========================================================================
api.registerCli(
({ program }) => {
const memory = program
.command("ltm")
.description("Long-term memory plugin commands");
memory
.command("list")
.description("List memories")
.action(async () => {
const count = await db.count();
console.log(`Total memories: ${count}`);
});
memory
.command("search")
.description("Search memories")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "5")
.action(async (query, opts) => {
const vector = await embeddings.embed(query);
const results = await db.search(vector, parseInt(opts.limit), 0.3);
// Strip vectors for output
const output = results.map((r) => ({
id: r.entry.id,
text: r.entry.text,
category: r.entry.category,
importance: r.entry.importance,
score: r.score,
}));
console.log(JSON.stringify(output, null, 2));
});
memory
.command("stats")
.description("Show memory statistics")
.action(async () => {
const count = await db.count();
console.log(`Total memories: ${count}`);
});
},
{ commands: ["ltm"] },
);
// ========================================================================
// Lifecycle Hooks
// ========================================================================
// Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) {
api.on("before_agent_start", async (event) => {
if (!event.prompt || event.prompt.length < 5) return;
try {
const vector = await embeddings.embed(event.prompt);
const results = await db.search(vector, 3, 0.3);
if (results.length === 0) return;
const memoryContext = results
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
.join("\n");
api.logger.info?.(
`memory: injecting ${results.length} memories into context`,
);
return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
};
} catch (err) {
api.logger.warn(`memory: recall failed: ${String(err)}`);
}
});
}
// Auto-capture: analyze and store important information after agent ends
if (cfg.autoCapture) {
api.on("agent_end", async (event) => {
if (!event.success || !event.messages || event.messages.length === 0) {
return;
}
try {
// Extract text content from messages (handling unknown[] type)
const texts: string[] = [];
for (const msg of event.messages) {
// Type guard for message object
if (!msg || typeof msg !== "object") continue;
const msgObj = msg as Record<string, unknown>;
// Only process user and assistant messages
const role = msgObj.role;
if (role !== "user" && role !== "assistant") continue;
const content = msgObj.content;
// Handle string content directly
if (typeof content === "string") {
texts.push(content);
continue;
}
// Handle array content (content blocks)
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as Record<string, unknown>).type === "text" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
texts.push((block as Record<string, unknown>).text as string);
}
}
}
}
// Filter for capturable content
const toCapture = texts.filter(
(text) => text && shouldCapture(text),
);
if (toCapture.length === 0) return;
// Store each capturable piece (limit to 3 per conversation)
let stored = 0;
for (const text of toCapture.slice(0, 3)) {
const category = detectCategory(text);
const vector = await embeddings.embed(text);
// Check for duplicates (high similarity threshold)
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) continue;
await db.store({
text,
vector,
importance: 0.7,
category,
});
stored++;
}
if (stored > 0) {
api.logger.info(`memory: auto-captured ${stored} memories`);
}
} catch (err) {
api.logger.warn(`memory: capture failed: ${String(err)}`);
}
});
}
// ========================================================================
// Service
// ========================================================================
api.registerService({
id: "memory",
start: () => {
api.logger.info(
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
);
},
stop: () => {
api.logger.info("memory: stopped");
},
});
},
};
export default memoryPlugin;
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@clawdbot/memory",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": {
"@sinclair/typebox": "0.34.47",
"@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
},
"clawdbot": {
"extensions": ["./index.ts"]
}
}
+331
View File
@@ -258,6 +258,18 @@ importers:
specifier: 40.0.0 specifier: 40.0.0
version: 40.0.0 version: 40.0.0
extensions/memory:
dependencies:
'@lancedb/lancedb':
specifier: ^0.15.0
version: 0.15.0(apache-arrow@18.1.0)
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
openai:
specifier: ^4.77.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
extensions/memory-core: extensions/memory-core:
dependencies: dependencies:
clawdbot: clawdbot:
@@ -958,6 +970,62 @@ packages:
'@kwsites/promise-deferred@1.1.1': '@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lancedb/lancedb-darwin-arm64@0.15.0':
resolution: {integrity: sha512-e6eiS1dUdSx3G3JXFEn5bk6I26GR7UM2QwQ1YMrTsg7IvGDqKmXc/s5j4jpJH0mzm7rwqh+OAILPIjr7DoUCDA==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [darwin]
'@lancedb/lancedb-darwin-x64@0.15.0':
resolution: {integrity: sha512-kEgigrqKf954egDbUdIp86tjVfFmTCTcq2Hydw/WLc+LI++46aeT2MsJv0CQpkNFMfh/T2G18FsDYLKH0zTaow==}
engines: {node: '>= 18'}
cpu: [x64]
os: [darwin]
'@lancedb/lancedb-linux-arm64-gnu@0.15.0':
resolution: {integrity: sha512-TnpbBT9kaSYQqastJ+S5jm4S5ZYBx18X8PHQ1ic3yMIdPTjCWauj+owDovOpiXK9ucjmi/FnUp8bKNxGnlqmEg==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
'@lancedb/lancedb-linux-arm64-musl@0.15.0':
resolution: {integrity: sha512-fe8LnC9YKbLgEJiLQhyVj+xz1d1RgWKs+rLSYPxaD3xQBo3kMC94Esq+xfrdNkSFvPgchRTvBA9jDYJjJL8rcg==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
'@lancedb/lancedb-linux-x64-gnu@0.15.0':
resolution: {integrity: sha512-0lKEc3M06ax3RozBbxHuNN9qWqhJUiKDnRC3ttsbmo4VrOUBvAO3fKoaRkjZhAA8q4+EdhZnCaQZezsk60f7Ag==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
'@lancedb/lancedb-linux-x64-musl@0.15.0':
resolution: {integrity: sha512-ls+ikV7vWyVnqVT7bMmuqfGCwVR5JzPIfJ5iZ4rkjU4iTIQRpY7u/cTe9rGKt/+psliji8x6PPZHpfdGXHmleQ==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
'@lancedb/lancedb-win32-arm64-msvc@0.15.0':
resolution: {integrity: sha512-C30A+nDaJ4jhjN76hRcp28Eq+G48SR9wO3i1zGm0ZAEcRV1t9O1fAp6g18IPT65Qyu/hXJBgBdVHtent+qg9Ng==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [win32]
'@lancedb/lancedb-win32-x64-msvc@0.15.0':
resolution: {integrity: sha512-amXzIAxqrHyp+c9TpIDI8ze1uCqWC6HXQIoXkoMQrBXoUUo8tJORH2yGAsa3TSgjZDDjg0HPA33dYLhOLk1m8g==}
engines: {node: '>= 18'}
cpu: [x64]
os: [win32]
'@lancedb/lancedb@0.15.0':
resolution: {integrity: sha512-qm3GXLA17/nFGUwrOEuFNW0Qg2gvCtp+yAs6qoCM6vftIreqzp8d4Hio6eG/YojS9XqPnR2q+zIeIFy12Ywvxg==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@lit-labs/signals@0.2.0': '@lit-labs/signals@0.2.0':
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==} resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
@@ -1954,6 +2022,9 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
'@thi.ng/bitstream@2.4.37': '@thi.ng/bitstream@2.4.37':
resolution: {integrity: sha512-ghVt+/73cChlhHDNQH9+DnxvoeVYYBu7AYsS0Gvwq25fpCa4LaqnEk5LAJfsY043HInwcV7/0KGO7P+XZCzumQ==} resolution: {integrity: sha512-ghVt+/73cChlhHDNQH9+DnxvoeVYYBu7AYsS0Gvwq25fpCa4LaqnEk5LAJfsY043HInwcV7/0KGO7P+XZCzumQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1988,6 +2059,12 @@ packages:
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/command-line-usage@5.0.4':
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -2039,9 +2116,18 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@10.17.60': '@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
'@types/node@24.10.7': '@types/node@24.10.7':
resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==} resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==}
@@ -2184,6 +2270,10 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-formats@3.0.1: ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies: peerDependencies:
@@ -2228,6 +2318,10 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
apache-arrow@18.1.0:
resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==}
hasBin: true
aproba@2.1.0: aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
@@ -2239,6 +2333,14 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-back@3.1.0:
resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
engines: {node: '>=6'}
array-back@6.2.2:
resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
engines: {node: '>=12.17'}
assertion-error@2.0.1: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2356,6 +2458,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
engines: {node: '>=12'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2441,6 +2547,14 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'}
command-line-usage@7.0.3:
resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
engines: {node: '>=12.20.0'}
commander@10.0.1: commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2721,6 +2835,13 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
find-replace@3.0.0:
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
engines: {node: '>=4.0.0'}
flatbuffers@24.12.23:
resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==}
follow-redirects@1.15.11: follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -2734,10 +2855,17 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.5: form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
formdata-polyfill@4.0.10: formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -2896,6 +3024,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iconv-lite@0.7.2: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3023,6 +3154,10 @@ packages:
json-bigint@1.0.0: json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'}
json-schema-to-ts@3.1.1: json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -3172,6 +3307,9 @@ packages:
lit@3.3.2: lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0: lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
@@ -3477,6 +3615,18 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.10.0: openai@6.10.0:
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true hasBin: true
@@ -3765,6 +3915,9 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
require-directory@2.1.1: require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4027,6 +4180,10 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tailwind-merge@3.4.0: tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -4130,6 +4287,14 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typical@4.0.0:
resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
engines: {node: '>=8'}
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
uc.micro@2.1.0: uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -4143,6 +4308,12 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -4280,6 +4451,10 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -4315,6 +4490,10 @@ packages:
wordwrap@1.0.0: wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5223,6 +5402,44 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': '@kwsites/promise-deferred@1.1.1':
optional: true optional: true
'@lancedb/lancedb-darwin-arm64@0.15.0':
optional: true
'@lancedb/lancedb-darwin-x64@0.15.0':
optional: true
'@lancedb/lancedb-linux-arm64-gnu@0.15.0':
optional: true
'@lancedb/lancedb-linux-arm64-musl@0.15.0':
optional: true
'@lancedb/lancedb-linux-x64-gnu@0.15.0':
optional: true
'@lancedb/lancedb-linux-x64-musl@0.15.0':
optional: true
'@lancedb/lancedb-win32-arm64-msvc@0.15.0':
optional: true
'@lancedb/lancedb-win32-x64-msvc@0.15.0':
optional: true
'@lancedb/lancedb@0.15.0(apache-arrow@18.1.0)':
dependencies:
apache-arrow: 18.1.0
reflect-metadata: 0.2.2
optionalDependencies:
'@lancedb/lancedb-darwin-arm64': 0.15.0
'@lancedb/lancedb-darwin-x64': 0.15.0
'@lancedb/lancedb-linux-arm64-gnu': 0.15.0
'@lancedb/lancedb-linux-arm64-musl': 0.15.0
'@lancedb/lancedb-linux-x64-gnu': 0.15.0
'@lancedb/lancedb-linux-x64-musl': 0.15.0
'@lancedb/lancedb-win32-arm64-msvc': 0.15.0
'@lancedb/lancedb-win32-x64-msvc': 0.15.0
'@lit-labs/signals@0.2.0': '@lit-labs/signals@0.2.0':
dependencies: dependencies:
lit: 3.3.2 lit: 3.3.2
@@ -6298,6 +6515,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.18':
dependencies:
tslib: 2.8.1
'@thi.ng/bitstream@2.4.37': '@thi.ng/bitstream@2.4.37':
dependencies: dependencies:
'@thi.ng/errors': 2.6.0 '@thi.ng/errors': 2.6.0
@@ -6341,6 +6562,10 @@ snapshots:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
assertion-error: 2.0.1 assertion-error: 2.0.1
'@types/command-line-args@5.2.3': {}
'@types/command-line-usage@5.0.4': {}
'@types/connect@3.4.38': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 25.0.7 '@types/node': 25.0.7
@@ -6402,8 +6627,21 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.0.7
form-data: 4.0.5
'@types/node@10.17.60': {} '@types/node@10.17.60': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.30':
dependencies:
undici-types: 6.21.0
'@types/node@24.10.7': '@types/node@24.10.7':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
@@ -6608,6 +6846,10 @@ snapshots:
agent-base@7.1.4: {} agent-base@7.1.4: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@3.0.1(ajv@8.17.1): ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies: optionalDependencies:
ajv: 8.17.1 ajv: 8.17.1
@@ -6643,6 +6885,18 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
apache-arrow@18.1.0:
dependencies:
'@swc/helpers': 0.5.18
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 20.19.30
command-line-args: 5.2.1
command-line-usage: 7.0.3
flatbuffers: 24.12.23
json-bignum: 0.0.3
tslib: 2.8.1
aproba@2.1.0: aproba@2.1.0:
optional: true optional: true
@@ -6654,6 +6908,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
array-back@3.1.0: {}
array-back@6.2.2: {}
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.10: ast-v8-to-istanbul@0.3.10:
@@ -6790,6 +7048,10 @@ snapshots:
chai@6.2.2: {} chai@6.2.2: {}
chalk-template@0.4.0:
dependencies:
chalk: 4.1.2
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -6899,6 +7161,20 @@ snapshots:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
command-line-args@5.2.1:
dependencies:
array-back: 3.1.0
find-replace: 3.0.0
lodash.camelcase: 4.3.0
typical: 4.0.0
command-line-usage@7.0.3:
dependencies:
array-back: 6.2.2
chalk-template: 0.4.0
table-layout: 4.1.1
typical: 7.3.0
commander@10.0.1: commander@10.0.1:
optional: true optional: true
@@ -7196,6 +7472,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
find-replace@3.0.0:
dependencies:
array-back: 3.1.0
flatbuffers@24.12.23: {}
follow-redirects@1.15.11(debug@4.4.3): follow-redirects@1.15.11(debug@4.4.3):
optionalDependencies: optionalDependencies:
debug: 4.4.3 debug: 4.4.3
@@ -7205,6 +7487,8 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.5: form-data@4.0.5:
dependencies: dependencies:
asynckit: 0.4.0 asynckit: 0.4.0
@@ -7213,6 +7497,11 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
mime-types: 2.1.35 mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
formdata-polyfill@4.0.10: formdata-polyfill@4.0.10:
dependencies: dependencies:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
@@ -7410,6 +7699,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
iconv-lite@0.7.2: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -7533,6 +7826,8 @@ snapshots:
dependencies: dependencies:
bignumber.js: 9.3.1 bignumber.js: 9.3.1
json-bignum@0.0.3: {}
json-schema-to-ts@3.1.1: json-schema-to-ts@3.1.1:
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -7693,6 +7988,8 @@ snapshots:
lit-element: 4.2.2 lit-element: 4.2.2
lit-html: 3.3.2 lit-html: 3.3.2
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {} lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
@@ -8036,6 +8333,21 @@ snapshots:
mimic-function: 5.0.1 mimic-function: 5.0.1
optional: true optional: true
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.19.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
openai@6.10.0(ws@8.19.0)(zod@4.3.5): openai@6.10.0(ws@8.19.0)(zod@4.3.5):
optionalDependencies: optionalDependencies:
ws: 8.19.0 ws: 8.19.0
@@ -8366,6 +8678,8 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
reflect-metadata@0.2.2: {}
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
@@ -8708,6 +9022,11 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
wordwrapjs: 5.1.1
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.17): tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.17):
@@ -8795,6 +9114,10 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
typical@4.0.0: {}
typical@7.3.0: {}
uc.micro@2.1.0: {} uc.micro@2.1.0: {}
uhtml@5.0.9: uhtml@5.0.9:
@@ -8805,6 +9128,10 @@ snapshots:
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici@7.18.2: {} undici@7.18.2: {}
@@ -8906,6 +9233,8 @@ snapshots:
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {} whatwg-fetch@3.6.20: {}
@@ -8944,6 +9273,8 @@ snapshots:
wordwrap@1.0.0: {} wordwrap@1.0.0: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
+57 -2
View File
@@ -64,8 +64,9 @@ import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js"; import { splitSdkTools } from "../tool-split.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { mapThinkingLevel } from "../utils.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -458,9 +459,40 @@ export async function runEmbeddedAttempt(
} }
} }
// Get hook runner once for both before_agent_start and agent_end hooks
const hookRunner = getGlobalHookRunner();
let promptError: unknown = null; let promptError: unknown = null;
try { try {
const promptStartedAt = Date.now(); const promptStartedAt = Date.now();
// Run before_agent_start hooks to allow plugins to inject context
let effectivePrompt = params.prompt;
if (hookRunner?.hasHooks("before_agent_start")) {
try {
const hookResult = await hookRunner.runBeforeAgentStart(
{
prompt: params.prompt,
messages: activeSession.messages,
},
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
);
if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`;
log.debug(
`hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`,
);
}
} catch (hookErr) {
log.warn(`before_agent_start hook failed: ${String(hookErr)}`);
}
}
log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`);
// Repair orphaned trailing user messages so new prompts don't violate role ordering. // Repair orphaned trailing user messages so new prompts don't violate role ordering.
@@ -480,7 +512,7 @@ export async function runEmbeddedAttempt(
} }
try { try {
await abortable(activeSession.prompt(params.prompt, { images: params.images })); await abortable(activeSession.prompt(effectivePrompt, { images: params.images }));
} catch (err) { } catch (err) {
promptError = err; promptError = err;
} finally { } finally {
@@ -501,6 +533,29 @@ export async function runEmbeddedAttempt(
messagesSnapshot = activeSession.messages.slice(); messagesSnapshot = activeSession.messages.slice();
sessionIdUsed = activeSession.sessionId; sessionIdUsed = activeSession.sessionId;
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await
if (hookRunner?.hasHooks("agent_end")) {
hookRunner
.runAgentEnd(
{
messages: messagesSnapshot,
success: !aborted && !promptError,
error: promptError ? describeUnknownError(promptError) : undefined,
durationMs: Date.now() - promptStartedAt,
},
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
)
.catch((err) => {
log.warn(`agent_end hook failed: ${err}`);
});
}
} finally { } finally {
clearTimeout(abortTimer); clearTimeout(abortTimer);
if (abortWarnTimer) clearTimeout(abortWarnTimer); if (abortWarnTimer) clearTimeout(abortWarnTimer);
@@ -52,7 +52,7 @@ describe("Agent-specific sandbox config", () => {
it( it(
"should use global sandbox config when no agent-specific config exists", "should use global sandbox config when no agent-specific config exists",
{ timeout: 60_000 }, { timeout: 15_000 },
async () => { async () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("sandbox config merges", () => { describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", { timeout: 60_000 }, async () => { it("resolves sandbox scope deterministically", { timeout: 15_000 }, async () => {
const { resolveSandboxScope } = await import("./sandbox.js"); const { resolveSandboxScope } = await import("./sandbox.js");
expect(resolveSandboxScope({})).toBe("agent"); expect(resolveSandboxScope({})).toBe("agent");
@@ -97,7 +97,7 @@ afterEach(() => {
}); });
describe("trigger handling", () => { describe("trigger handling", () => {
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => { it("stages inbound media into the sandbox workspace", { timeout: 15_000 }, async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound"); const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true }); await fs.mkdir(inboundDir, { recursive: true });
+1 -1
View File
@@ -26,7 +26,7 @@ vi.mock("../runtime.js", () => ({
})); }));
describe("cron cli", () => { describe("cron cli", () => {
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => { it("trims model and thinking on cron add", { timeout: 30_000 }, async () => {
callGatewayFromCli.mockClear(); callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js"); const { registerCronCli } = await import("./cron-cli.js");
+27 -17
View File
@@ -6,12 +6,6 @@ import { setVerbose } from "../globals.js";
import { withProgress, withProgressTotals } from "./progress.js"; import { withProgress, withProgressTotals } from "./progress.js";
import { formatErrorMessage, withManager } from "./cli-utils.js"; import { formatErrorMessage, withManager } from "./cli-utils.js";
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.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 { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js"; import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -137,8 +131,6 @@ export function registerMemoryCli(program: Command) {
const warn = (text: string) => colorize(rich, theme.warn, text); const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text); const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`); const label = (text: string) => muted(`${text}:`);
const colorForTone = (tone: Tone) =>
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
const lines = [ const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`, `${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted( `${label("Provider")} ${info(status.provider)} ${muted(
@@ -172,9 +164,18 @@ export function registerMemoryCli(program: Command) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
} }
if (status.vector) { if (status.vector) {
const vectorState = resolveMemoryVectorState(status.vector); const vectorState = status.vector.enabled
const vectorColor = colorForTone(vectorState.tone); ? status.vector.available
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`); ? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
? theme.success
: vectorState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
if (status.vector.dims) { if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
} }
@@ -186,22 +187,31 @@ export function registerMemoryCli(program: Command) {
} }
} }
if (status.fts) { if (status.fts) {
const ftsState = resolveMemoryFtsState(status.fts); const ftsState = status.fts.enabled
const ftsColor = colorForTone(ftsState.tone); ? status.fts.available
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`); ? "ready"
: "unavailable"
: "disabled";
const ftsColor =
ftsState === "ready"
? theme.success
: ftsState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
if (status.fts.error) { if (status.fts.error) {
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`); lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
} }
} }
if (status.cache) { if (status.cache) {
const cacheState = resolveMemoryCacheState(status.cache); const cacheState = status.cache.enabled ? "enabled" : "disabled";
const cacheColor = colorForTone(cacheState.tone); const cacheColor = status.cache.enabled ? theme.success : theme.muted;
const suffix = const suffix =
status.cache.enabled && typeof status.cache.entries === "number" status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)` ? ` (${status.cache.entries} entries)`
: ""; : "";
lines.push( lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`, `${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
); );
if (status.cache.enabled && typeof status.cache.maxEntries === "number") { if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`); lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
+1 -1
View File
@@ -14,7 +14,7 @@ vi.mock("../commands/models.js", async () => {
}); });
describe("models cli", () => { describe("models cli", () => {
it("registers github-copilot login command", { timeout: 60_000 }, async () => { it("registers github-copilot login command", { timeout: 15_000 }, async () => {
const { Command } = await import("commander"); const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js"); const { registerModelsCli } = await import("./models-cli.js");
@@ -322,7 +322,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
})); }));
describe("doctor command", () => { describe("doctor command", () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 60_000 }, async () => { it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 30_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({ readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json", path: "/tmp/clawdbot.json",
exists: true, exists: true,
@@ -366,7 +366,7 @@ describe("doctor command", () => {
expect(written.routing).toBeUndefined(); expect(written.routing).toBeUndefined();
}); });
it("migrates legacy gateway services", { timeout: 60_000 }, async () => { it("migrates legacy gateway services", { timeout: 30_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({ readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json", path: "/tmp/clawdbot.json",
exists: true, exists: true,
+21 -18
View File
@@ -7,12 +7,6 @@ import type { RuntimeEnv } from "../runtime.js";
import { runSecurityAudit } from "../security/audit.js"; import { runSecurityAudit } from "../security/audit.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.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 { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js"; import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary } from "./status.daemon.js"; import { getDaemonStatusSummary } from "./status.daemon.js";
@@ -256,24 +250,33 @@ export async function statusCommand(
parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`); parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`);
if (memory.sources?.length) parts.push(`sources ${memory.sources.join(", ")}`); if (memory.sources?.length) parts.push(`sources ${memory.sources.join(", ")}`);
if (memoryPlugin.slot) parts.push(`plugin ${memoryPlugin.slot}`); 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; const vector = memory.vector;
if (vector) { parts.push(
const state = resolveMemoryVectorState(vector); vector?.enabled === false
const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`; ? muted("vector off")
parts.push(colorByTone(state.tone, label)); : vector?.available
} ? ok("vector ready")
: vector?.available === false
? warn("vector unavailable")
: muted("vector unknown"),
);
const fts = memory.fts; const fts = memory.fts;
if (fts) { if (fts) {
const state = resolveMemoryFtsState(fts); parts.push(
const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`; fts.enabled === false
parts.push(colorByTone(state.tone, label)); ? muted("fts off")
: fts.available
? ok("fts ready")
: warn("fts unavailable"),
);
} }
const cache = memory.cache; const cache = memory.cache;
if (cache) { if (cache) {
const summary = resolveMemoryCacheSummary(cache); parts.push(
parts.push(colorByTone(summary.tone, summary.text)); cache.enabled
? ok(`cache on${typeof cache.entries === "number" ? ` (${cache.entries})` : ""}`)
: muted("cache off"),
);
} }
return parts.join(" · "); return parts.join(" · ");
})(); })();
+1 -1
View File
@@ -33,7 +33,7 @@ beforeEach(() => {
}); });
describe("discord native commands", () => { describe("discord native commands", () => {
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => { it("streams tool results for native slash commands", { timeout: 30_000 }, async () => {
const { ChannelType } = await import("@buape/carbon"); const { ChannelType } = await import("@buape/carbon");
const { createDiscordNativeCommand } = await import("./monitor.js"); const { createDiscordNativeCommand } = await import("./monitor.js");
@@ -333,7 +333,7 @@ describe("gateway server agent", () => {
await server.close(); await server.close();
}); });
test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => { test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
+2 -2
View File
@@ -26,7 +26,7 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
} }
describe("gateway server auth/connect", () => { describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { test("closes silent handshakes after timeout", { timeout: 30_000 }, async () => {
vi.useRealTimers(); vi.useRealTimers();
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000); const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
@@ -129,7 +129,7 @@ describe("gateway server auth/connect", () => {
test( test(
"invalid connect params surface in response and close reason", "invalid connect params surface in response and close reason",
{ timeout: 60_000 }, { timeout: 15000 },
async () => { async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
+27 -27
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
@@ -10,27 +10,11 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks(); 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", () => { describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => { test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); const prevToken = process.env.TELEGRAM_BOT_TOKEN;
const result = await startServerWithClient(); delete process.env.TELEGRAM_BOT_TOKEN;
servers.push(result); const { server, ws } = await startServerWithClient();
const { ws } = result;
await connectOk(ws); await connectOk(ws);
const res = await rpcReq<{ const res = await rpcReq<{
@@ -56,12 +40,18 @@ describe("gateway server channels", () => {
expect(signal?.configured).toBe(false); expect(signal?.configured).toBe(false);
expect(signal?.probe).toBeUndefined(); expect(signal?.probe).toBeUndefined();
expect(signal?.lastProbeAt).toBeNull(); 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 () => { test("channels.logout reports no session when missing", async () => {
const result = await startServerWithClient(); const { server, ws } = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws); await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", { const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
@@ -70,10 +60,14 @@ describe("gateway server channels", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.payload?.channel).toBe("whatsapp"); expect(res.payload?.channel).toBe("whatsapp");
expect(res.payload?.cleared).toBe(false); expect(res.payload?.cleared).toBe(false);
ws.close();
await server.close();
}); });
test("channels.logout clears telegram bot token from config", async () => { test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers(); const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({ await writeConfigFile({
channels: { channels: {
@@ -84,9 +78,7 @@ describe("gateway server channels", () => {
}, },
}); });
const result = await startServerWithClient(); const { server, ws } = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws); await connectOk(ws);
const res = await rpcReq<{ const res = await rpcReq<{
@@ -103,5 +95,13 @@ describe("gateway server channels", () => {
expect(snap.valid).toBe(true); expect(snap.valid).toBe(true);
expect(snap.config?.channels?.telegram?.botToken).toBeUndefined(); expect(snap.config?.channels?.telegram?.botToken).toBeUndefined();
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); 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", () => { describe("gateway server chat", () => {
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => { test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -105,7 +105,7 @@ describe("gateway server chat", () => {
await server.close(); await server.close();
}); });
test("chat.abort cancels an in-flight chat.send", { timeout: 60_000 }, async () => { test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -263,7 +263,7 @@ describe("gateway server chat", () => {
await server.close(); await server.close();
}); });
test("chat.send treats /stop as an out-of-band abort", { timeout: 60_000 }, async () => { test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
+25 -25
View File
@@ -1,5 +1,7 @@
import { readRestartSentinel } from "../infra/restart-sentinel.js"; import fs from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest"; import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { import {
connectOk, connectOk,
@@ -10,26 +12,13 @@ import {
installGatewayTestHooks(); 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", () => { describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => { it("writes config, stores sentinel, and schedules restart", async () => {
const result = await startServerWithClient(); vi.useFakeTimers();
servers.push(result); const sigusr1 = vi.fn();
const { ws } = result; process.on("SIGUSR1", sigusr1);
const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
const id = "req-1"; const id = "req-1";
@@ -51,14 +40,22 @@ describe("gateway config.apply", () => {
); );
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
const sentinel = await readRestartSentinel(); await vi.advanceTimersByTimeAsync(0);
expect(sentinel?.payload.kind).toBe("config-apply"); 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();
}); });
it("rejects invalid raw config", async () => { it("rejects invalid raw config", async () => {
const result = await startServerWithClient(); const { server, ws } = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws); await connectOk(ws);
const id = "req-2"; const id = "req-2";
@@ -77,5 +74,8 @@ describe("gateway config.apply", () => {
(o) => o.type === "res" && o.id === id, (o) => o.type === "res" && o.id === id,
); );
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
ws.close();
await server.close();
}); });
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
describe("gateway server health/presence", () => { describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => { test("connect + health + presence + status succeed", { timeout: 20_000 }, async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
+1 -1
View File
@@ -46,7 +46,7 @@ describe("gateway server misc", () => {
} }
}); });
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => { test("send dedupes by idempotencyKey", { timeout: 20_000 }, async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
+1 -1
View File
@@ -64,7 +64,7 @@ describe("gateway server models + voicewake", () => {
test( test(
"voicewake.get returns defaults and voicewake.set broadcasts", "voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 60_000 }, { timeout: 30_000 },
async () => { async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir); const restoreHome = setTempHome(homeDir);
+107 -87
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "../agents/clawdbot-tools.js"; import { createClawdbotTools } from "../agents/clawdbot-tools.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
@@ -13,25 +13,11 @@ import {
installGatewayTestHooks(); 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", () => { describe("sessions_send gateway loopback", () => {
it("returns reply when lifecycle ends before agent.wait", async () => { it("returns reply when lifecycle ends before agent.wait", async () => {
const port = await getFreePort(); const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
@@ -77,37 +63,44 @@ describe("sessions_send gateway loopback", () => {
}); });
}); });
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"); const result = await tool.execute("call-loopback", {
if (!tool) throw new Error("missing sessions_send tool"); 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 result = await tool.execute("call-loopback", { const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
sessionKey: "main", expect(firstCall?.lane).toBe("nested");
message: "ping", } finally {
timeoutSeconds: 5, if (prevPort === undefined) {
}); delete process.env.CLAWDBOT_GATEWAY_PORT;
const details = result.details as { } else {
status?: string; process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
reply?: string; }
sessionKey?: string; await server.close();
}; }
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", () => { describe("sessions_send label lookup", () => {
it("finds session by label and sends message", { timeout: 60_000 }, async () => { it("finds session by label and sends message", { timeout: 15_000 }, async () => {
const port = await getFreePort(); const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
servers.push(server);
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
spy.mockImplementation(async (opts) => { spy.mockImplementation(async (opts) => {
const params = opts as { const params = opts as {
@@ -141,69 +134,96 @@ describe("sessions_send label lookup", () => {
}); });
}); });
// First, create a session with a label via sessions.patch try {
const { callGateway } = await import("./call.js"); // First, create a session with a label via sessions.patch
await callGateway({ const { callGateway } = await import("./call.js");
method: "sessions.patch", await callGateway({
params: { key: "test-labeled-session", label: "my-test-worker" }, method: "sessions.patch",
timeoutMs: 5000, params: { key: "test-labeled-session", label: "my-test-worker" },
}); timeoutMs: 5000,
});
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
// Send using label instead of sessionKey // Send using label instead of sessionKey
const result = await tool.execute("call-by-label", { const result = await tool.execute("call-by-label", {
label: "my-test-worker", label: "my-test-worker",
message: "hello labeled session", message: "hello labeled session",
timeoutSeconds: 5, timeoutSeconds: 5,
}); });
const details = result.details as { const details = result.details as {
status?: string; status?: string;
reply?: string; reply?: string;
sessionKey?: string; sessionKey?: string;
}; };
expect(details.status).toBe("ok"); expect(details.status).toBe("ok");
expect(details.reply).toBe("labeled response"); expect(details.reply).toBe("labeled response");
expect(details.sessionKey).toBe("agent:main:test-labeled-session"); 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();
}
}); });
it("returns error when label not found", { timeout: 60_000 }, async () => { it("returns error when label not found", { timeout: 15_000 }, async () => {
const port = await getFreePort(); const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
servers.push(server);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); try {
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", { const result = await tool.execute("call-missing-label", {
label: "nonexistent-label", label: "nonexistent-label",
message: "hello", message: "hello",
timeoutSeconds: 5, timeoutSeconds: 5,
}); });
const details = result.details as { status?: string; error?: string }; const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error"); expect(details.status).toBe("error");
expect(details.error).toContain("No session found with label"); 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();
}
}); });
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => { it("returns error when neither sessionKey nor label provided", { timeout: 15_000 }, async () => {
const port = await getFreePort(); const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
servers.push(server);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); try {
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", { const result = await tool.execute("call-no-key", {
message: "hello", message: "hello",
timeoutSeconds: 5, timeoutSeconds: 5,
}); });
const details = result.details as { status?: string; error?: string }; const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error"); expect(details.status).toBe("error");
expect(details.error).toContain("Either sessionKey or label is required"); 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();
}
}); });
}); });
@@ -5,6 +5,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
plugins: [], plugins: [],
tools: [], tools: [],
hooks: [], hooks: [],
typedHooks: [],
channels: [], channels: [],
providers: [], providers: [],
gatewayHandlers: {}, gatewayHandlers: {},
-16
View File
@@ -1,16 +0,0 @@
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
View File
@@ -1,55 +0,0 @@
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}`;
}
+273 -39
View File
@@ -24,10 +24,12 @@ import {
runOpenAiEmbeddingBatches, runOpenAiEmbeddingBatches,
} from "./openai-batch.js"; } from "./openai-batch.js";
import { import {
buildFileEntry,
chunkMarkdown, chunkMarkdown,
ensureDir, ensureDir,
hashText, hashText,
isMemoryPath, isMemoryPath,
listMemoryFiles,
type MemoryChunk, type MemoryChunk,
type MemoryFileEntry, type MemoryFileEntry,
normalizeRelPath, normalizeRelPath,
@@ -36,13 +38,8 @@ import {
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js"; import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
import { searchKeyword, searchVector } from "./manager-search.js"; import { searchKeyword, searchVector } from "./manager-search.js";
import { ensureMemoryIndexSchema } from "./memory-schema.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 { requireNodeSqlite } from "./sqlite.js";
import { loadSqliteVecExtension } from "./sqlite-vec.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"; type MemorySource = "memory" | "sessions";
@@ -64,6 +61,15 @@ type MemoryIndexMeta = {
vectorDims?: number; vectorDims?: number;
}; };
type SessionFileEntry = {
path: string;
absPath: string;
mtimeMs: number;
size: number;
hash: string;
content: string;
};
type MemorySyncProgressUpdate = { type MemorySyncProgressUpdate = {
completed: number; completed: number;
total: number; total: number;
@@ -151,7 +157,7 @@ export class MemoryIndexManager {
const settings = resolveMemorySearchConfig(cfg, agentId); const settings = resolveMemorySearchConfig(cfg, agentId);
if (!settings) return null; if (!settings) return null;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const key = computeMemoryManagerCacheKey({ agentId, workspaceDir, settings }); const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}`;
const existing = INDEX_CACHE.get(key); const existing = INDEX_CACHE.get(key);
if (existing) return existing; if (existing) return existing;
const providerResult = await createEmbeddingProvider({ const providerResult = await createEmbeddingProvider({
@@ -194,13 +200,7 @@ export class MemoryIndexManager {
this.openAi = params.providerResult.openAi; this.openAi = params.providerResult.openAi;
this.sources = new Set(params.settings.sources); this.sources = new Set(params.settings.sources);
this.db = this.openDatabase(); this.db = this.openDatabase();
this.providerKey = computeEmbeddingProviderKey({ this.providerKey = this.computeProviderKey();
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 = { this.cache = {
enabled: params.settings.cache.enabled, enabled: params.settings.cache.enabled,
maxEntries: params.settings.cache.maxEntries, maxEntries: params.settings.cache.maxEntries,
@@ -714,43 +714,170 @@ export class MemoryIndexManager {
needsFullReindex: boolean; needsFullReindex: boolean;
progress?: MemorySyncProgressState; progress?: MemorySyncProgressState;
}) { }) {
await syncMemoryFiles({ const files = await listMemoryFiles(this.workspaceDir);
workspaceDir: this.workspaceDir, const fileEntries = await Promise.all(
db: this.db, files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
);
log.debug("memory sync: indexing memory files", {
files: fileEntries.length,
needsFullReindex: params.needsFullReindex, needsFullReindex: params.needsFullReindex,
progress: params.progress, batch: this.batch.enabled,
batchEnabled: this.batch.enabled,
concurrency: this.getIndexConcurrency(), 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: { private async syncSessionFiles(params: {
needsFullReindex: boolean; needsFullReindex: boolean;
progress?: MemorySyncProgressState; progress?: MemorySyncProgressState;
}) { }) {
await syncSessionFiles({ const files = await this.listSessionFiles();
agentId: this.agentId, const activePaths = new Set(files.map((file) => this.sessionPathForFile(file)));
db: this.db, const indexAll = params.needsFullReindex || this.sessionsDirtyFiles.size === 0;
needsFullReindex: params.needsFullReindex, log.debug("memory sync: indexing session files", {
progress: params.progress, files: files.length,
batchEnabled: this.batch.enabled, indexAll,
dirtyFiles: this.sessionsDirtyFiles.size,
batch: this.batch.enabled,
concurrency: this.getIndexConcurrency(), 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( private createSyncProgress(
@@ -866,6 +993,95 @@ export class MemoryIndexManager {
.run(META_KEY, value); .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 { private estimateEmbeddingTokens(text: string): number {
if (!text) return 0; if (!text) return 0;
return Math.ceil(text.length / EMBEDDING_APPROX_CHARS_PER_TOKEN); return Math.ceil(text.length / EMBEDDING_APPROX_CHARS_PER_TOKEN);
@@ -1017,6 +1233,24 @@ export class MemoryIndexManager {
return embeddings; 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( private async embedChunksWithBatch(
chunks: MemoryChunk[], chunks: MemoryChunk[],
entry: MemoryFileEntry | SessionFileEntry, entry: MemoryFileEntry | SessionFileEntry,
-22
View File
@@ -1,22 +0,0 @@
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
View File
@@ -1,103 +0,0 @@
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
View File
@@ -1,34 +0,0 @@
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
View File
@@ -1,102 +0,0 @@
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
View File
@@ -1,126 +0,0 @@
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 {}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Global Plugin Hook Runner
*
* Singleton hook runner that's initialized when plugins are loaded
* and can be called from anywhere in the codebase.
*/
import { createSubsystemLogger } from "../logging.js";
import { createHookRunner, type HookRunner } from "./hooks.js";
import type { PluginRegistry } from "./registry.js";
const log = createSubsystemLogger("plugins");
let globalHookRunner: HookRunner | null = null;
let globalRegistry: PluginRegistry | null = null;
/**
* Initialize the global hook runner with a plugin registry.
* Called once when plugins are loaded during gateway startup.
*/
export function initializeGlobalHookRunner(registry: PluginRegistry): void {
globalRegistry = registry;
globalHookRunner = createHookRunner(registry, {
logger: {
debug: (msg) => log.debug(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
},
catchErrors: true,
});
const hookCount = registry.hooks.length;
if (hookCount > 0) {
log.info(`hook runner initialized with ${hookCount} registered hooks`);
}
}
/**
* Get the global hook runner.
* Returns null if plugins haven't been loaded yet.
*/
export function getGlobalHookRunner(): HookRunner | null {
return globalHookRunner;
}
/**
* Get the global plugin registry.
* Returns null if plugins haven't been loaded yet.
*/
export function getGlobalPluginRegistry(): PluginRegistry | null {
return globalRegistry;
}
/**
* Check if any hooks are registered for a given hook name.
*/
export function hasGlobalHooks(hookName: Parameters<HookRunner["hasHooks"]>[0]): boolean {
return globalHookRunner?.hasHooks(hookName) ?? false;
}
/**
* Reset the global hook runner (for testing).
*/
export function resetGlobalHookRunner(): void {
globalHookRunner = null;
globalRegistry = null;
}
+400
View File
@@ -0,0 +1,400 @@
/**
* Plugin Hook Runner
*
* Provides utilities for executing plugin lifecycle hooks with proper
* error handling, priority ordering, and async support.
*/
import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterCompactionEvent,
PluginHookAfterToolCallEvent,
PluginHookAgentContext,
PluginHookAgentEndEvent,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeCompactionEvent,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSendingEvent,
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
PluginHookName,
PluginHookRegistration,
PluginHookSessionContext,
PluginHookSessionEndEvent,
PluginHookSessionStartEvent,
PluginHookToolContext,
} from "./types.js";
// Re-export types for consumers
export type {
PluginHookAgentContext,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookAgentEndEvent,
PluginHookBeforeCompactionEvent,
PluginHookAfterCompactionEvent,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSendingEvent,
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
PluginHookToolContext,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookAfterToolCallEvent,
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
};
export type HookRunnerLogger = {
debug?: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
export type HookRunnerOptions = {
logger?: HookRunnerLogger;
/** If true, errors in hooks will be caught and logged instead of thrown */
catchErrors?: boolean;
};
/**
* Get hooks for a specific hook name, sorted by priority (higher first).
*/
function getHooksForName<K extends PluginHookName>(
registry: PluginRegistry,
hookName: K,
): PluginHookRegistration<K>[] {
return (registry.typedHooks as PluginHookRegistration<K>[])
.filter((h) => h.hookName === hookName)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
/**
* Create a hook runner for a specific registry.
*/
export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) {
const logger = options.logger;
const catchErrors = options.catchErrors ?? true;
/**
* Run a hook that doesn't return a value (fire-and-forget style).
* All handlers are executed in parallel for performance.
*/
async function runVoidHook<K extends PluginHookName>(
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<void> {
const hooks = getHooksForName(registry, hookName);
if (hooks.length === 0) return;
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers)`);
const promises = hooks.map(async (hook) => {
try {
await (hook.handler as (event: unknown, ctx: unknown) => Promise<void>)(event, ctx);
} catch (err) {
const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`;
if (catchErrors) {
logger?.error(msg);
} else {
throw new Error(msg);
}
}
});
await Promise.all(promises);
}
/**
* Run a hook that can return a modifying result.
* Handlers are executed sequentially in priority order, and results are merged.
*/
async function runModifyingHook<K extends PluginHookName, TResult>(
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
): Promise<TResult | undefined> {
const hooks = getHooksForName(registry, hookName);
if (hooks.length === 0) return undefined;
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`);
let result: TResult | undefined;
for (const hook of hooks) {
try {
const handlerResult = await (
hook.handler as (event: unknown, ctx: unknown) => Promise<TResult>
)(event, ctx);
if (handlerResult !== undefined && handlerResult !== null) {
if (mergeResults && result !== undefined) {
result = mergeResults(result, handlerResult);
} else {
result = handlerResult;
}
}
} catch (err) {
const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`;
if (catchErrors) {
logger?.error(msg);
} else {
throw new Error(msg);
}
}
}
return result;
}
// =========================================================================
// Agent Hooks
// =========================================================================
/**
* Run before_agent_start hook.
* Allows plugins to inject context into the system prompt.
* Runs sequentially, merging systemPrompt and prependContext from all handlers.
*/
async function runBeforeAgentStart(
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeAgentStartResult | undefined> {
return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>(
"before_agent_start",
event,
ctx,
(acc, next) => ({
systemPrompt: next.systemPrompt ?? acc?.systemPrompt,
prependContext:
acc?.prependContext && next.prependContext
? `${acc.prependContext}\n\n${next.prependContext}`
: (next.prependContext ?? acc?.prependContext),
}),
);
}
/**
* Run agent_end hook.
* Allows plugins to analyze completed conversations.
* Runs in parallel (fire-and-forget).
*/
async function runAgentEnd(
event: PluginHookAgentEndEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("agent_end", event, ctx);
}
/**
* Run before_compaction hook.
*/
async function runBeforeCompaction(
event: PluginHookBeforeCompactionEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("before_compaction", event, ctx);
}
/**
* Run after_compaction hook.
*/
async function runAfterCompaction(
event: PluginHookAfterCompactionEvent,
ctx: PluginHookAgentContext,
): Promise<void> {
return runVoidHook("after_compaction", event, ctx);
}
// =========================================================================
// Message Hooks
// =========================================================================
/**
* Run message_received hook.
* Runs in parallel (fire-and-forget).
*/
async function runMessageReceived(
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,
): Promise<void> {
return runVoidHook("message_received", event, ctx);
}
/**
* Run message_sending hook.
* Allows plugins to modify or cancel outgoing messages.
* Runs sequentially.
*/
async function runMessageSending(
event: PluginHookMessageSendingEvent,
ctx: PluginHookMessageContext,
): Promise<PluginHookMessageSendingResult | undefined> {
return runModifyingHook<"message_sending", PluginHookMessageSendingResult>(
"message_sending",
event,
ctx,
(acc, next) => ({
content: next.content ?? acc?.content,
cancel: next.cancel ?? acc?.cancel,
}),
);
}
/**
* Run message_sent hook.
* Runs in parallel (fire-and-forget).
*/
async function runMessageSent(
event: PluginHookMessageSentEvent,
ctx: PluginHookMessageContext,
): Promise<void> {
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
// Tool Hooks
// =========================================================================
/**
* Run before_tool_call hook.
* Allows plugins to modify or block tool calls.
* Runs sequentially.
*/
async function runBeforeToolCall(
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
): Promise<PluginHookBeforeToolCallResult | undefined> {
return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>(
"before_tool_call",
event,
ctx,
(acc, next) => ({
params: next.params ?? acc?.params,
block: next.block ?? acc?.block,
blockReason: next.blockReason ?? acc?.blockReason,
}),
);
}
/**
* Run after_tool_call hook.
* Runs in parallel (fire-and-forget).
*/
async function runAfterToolCall(
event: PluginHookAfterToolCallEvent,
ctx: PluginHookToolContext,
): Promise<void> {
return runVoidHook("after_tool_call", event, ctx);
}
// =========================================================================
// Session Hooks
// =========================================================================
/**
* Run session_start hook.
* Runs in parallel (fire-and-forget).
*/
async function runSessionStart(
event: PluginHookSessionStartEvent,
ctx: PluginHookSessionContext,
): Promise<void> {
return runVoidHook("session_start", event, ctx);
}
/**
* Run session_end hook.
* Runs in parallel (fire-and-forget).
*/
async function runSessionEnd(
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
): Promise<void> {
return runVoidHook("session_end", event, ctx);
}
// =========================================================================
// Gateway Hooks
// =========================================================================
/**
* Run gateway_start hook.
* Runs in parallel (fire-and-forget).
*/
async function runGatewayStart(
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,
): Promise<void> {
return runVoidHook("gateway_start", event, ctx);
}
/**
* Run gateway_stop hook.
* Runs in parallel (fire-and-forget).
*/
async function runGatewayStop(
event: PluginHookGatewayStopEvent,
ctx: PluginHookGatewayContext,
): Promise<void> {
return runVoidHook("gateway_stop", event, ctx);
}
// =========================================================================
// Utility
// =========================================================================
/**
* Check if any hooks are registered for a given hook name.
*/
function hasHooks(hookName: PluginHookName): boolean {
return registry.typedHooks.some((h) => h.hookName === hookName);
}
/**
* Get count of registered hooks for a given hook name.
*/
function getHookCount(hookName: PluginHookName): number {
return registry.typedHooks.filter((h) => h.hookName === hookName).length;
}
return {
// Agent hooks
runBeforeAgentStart,
runAgentEnd,
runBeforeCompaction,
runAfterCompaction,
// Message hooks
runMessageReceived,
runMessageSending,
runMessageSent,
// Tool hooks
runBeforeToolCall,
runAfterToolCall,
// Session hooks
runSessionStart,
runSessionEnd,
// Gateway hooks
runGatewayStart,
runGatewayStop,
// Utility
hasHooks,
getHookCount,
};
}
export type HookRunner = ReturnType<typeof createHookRunner>;
+3
View File
@@ -8,6 +8,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js"; import { discoverClawdbotPlugins } from "./discovery.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js"; import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js"; import { setActivePluginRegistry } from "./runtime.js";
@@ -271,6 +272,7 @@ function createPluginRecord(params: {
cliCommands: [], cliCommands: [],
services: [], services: [],
httpHandlers: 0, httpHandlers: 0,
hookCount: 0,
configSchema: params.configSchema, configSchema: params.configSchema,
configUiHints: undefined, configUiHints: undefined,
configJsonSchema: undefined, configJsonSchema: undefined,
@@ -521,5 +523,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
registryCache.set(cacheKey, registry); registryCache.set(cacheKey, registry);
} }
setActivePluginRegistry(registry, cacheKey); setActivePluginRegistry(registry, cacheKey);
initializeGlobalHookRunner(registry);
return registry; return registry;
} }
+25
View File
@@ -22,6 +22,9 @@ import type {
PluginLogger, PluginLogger,
PluginOrigin, PluginOrigin,
PluginKind, PluginKind,
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
} from "./types.js"; } from "./types.js";
import type { PluginRuntime } from "./runtime/types.js"; import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js"; import type { HookEntry } from "../hooks/types.js";
@@ -94,6 +97,7 @@ export type PluginRecord = {
cliCommands: string[]; cliCommands: string[];
services: string[]; services: string[];
httpHandlers: number; httpHandlers: number;
hookCount: number;
configSchema: boolean; configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>; configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>; configJsonSchema?: Record<string, unknown>;
@@ -103,6 +107,7 @@ export type PluginRegistry = {
plugins: PluginRecord[]; plugins: PluginRecord[];
tools: PluginToolRegistration[]; tools: PluginToolRegistration[];
hooks: PluginHookRegistration[]; hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[]; channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[]; providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers; gatewayHandlers: GatewayRequestHandlers;
@@ -123,6 +128,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
plugins: [], plugins: [],
tools: [], tools: [],
hooks: [], hooks: [],
typedHooks: [],
channels: [], channels: [],
providers: [], providers: [],
gatewayHandlers: {}, gatewayHandlers: {},
@@ -346,6 +352,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}); });
}; };
const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord,
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number },
) => {
record.hookCount += 1;
registry.typedHooks.push({
pluginId: record.id,
hookName,
handler,
priority: opts?.priority,
source: record.source,
} as TypedPluginHookRegistration);
};
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
info: logger.info, info: logger.info,
warn: logger.warn, warn: logger.warn,
@@ -380,6 +402,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service), registerService: (service) => registerService(record, service),
resolvePath: (input: string) => resolveUserPath(input), resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
}; };
}; };
@@ -393,5 +416,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod, registerGatewayMethod,
registerCli, registerCli,
registerService, registerService,
registerHook,
registerTypedHook,
}; };
} }
+222
View File
@@ -200,6 +200,12 @@ export type ClawdbotPluginApi = {
registerService: (service: ClawdbotPluginService) => void; registerService: (service: ClawdbotPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void; registerProvider: (provider: ProviderPlugin) => void;
resolvePath: (input: string) => string; resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number },
) => void;
}; };
export type PluginOrigin = "bundled" | "global" | "workspace" | "config"; export type PluginOrigin = "bundled" | "global" | "workspace" | "config";
@@ -210,3 +216,219 @@ export type PluginDiagnostic = {
pluginId?: string; pluginId?: string;
source?: string; source?: string;
}; };
// ============================================================================
// Plugin Hooks
// ============================================================================
export type PluginHookName =
| "before_agent_start"
| "agent_end"
| "before_compaction"
| "after_compaction"
| "message_received"
| "message_sending"
| "message_sent"
| "before_tool_call"
| "after_tool_call"
| "session_start"
| "session_end"
| "gateway_start"
| "gateway_stop";
// Agent context shared across agent hooks
export type PluginHookAgentContext = {
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
messageProvider?: string;
};
// before_agent_start hook
export type PluginHookBeforeAgentStartEvent = {
prompt: string;
messages?: unknown[];
};
export type PluginHookBeforeAgentStartResult = {
systemPrompt?: string;
prependContext?: string;
};
// agent_end hook
export type PluginHookAgentEndEvent = {
messages: unknown[];
success: boolean;
error?: string;
durationMs?: number;
};
// Compaction hooks
export type PluginHookBeforeCompactionEvent = {
messageCount: number;
tokenCount?: number;
};
export type PluginHookAfterCompactionEvent = {
messageCount: number;
tokenCount?: number;
compactedCount: number;
};
// Message context
export type PluginHookMessageContext = {
channelId: string;
accountId?: string;
conversationId?: string;
};
// message_received hook
export type PluginHookMessageReceivedEvent = {
from: string;
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
};
// message_sending hook
export type PluginHookMessageSendingEvent = {
to: string;
content: string;
metadata?: Record<string, unknown>;
};
export type PluginHookMessageSendingResult = {
content?: string;
cancel?: boolean;
};
// message_sent hook
export type PluginHookMessageSentEvent = {
to: string;
content: string;
success: boolean;
error?: string;
};
// Tool context
export type PluginHookToolContext = {
agentId?: string;
sessionKey?: string;
toolName: string;
};
// before_tool_call hook
export type PluginHookBeforeToolCallEvent = {
toolName: string;
params: Record<string, unknown>;
};
export type PluginHookBeforeToolCallResult = {
params?: Record<string, unknown>;
block?: boolean;
blockReason?: string;
};
// after_tool_call hook
export type PluginHookAfterToolCallEvent = {
toolName: string;
params: Record<string, unknown>;
result?: unknown;
error?: string;
durationMs?: number;
};
// Session context
export type PluginHookSessionContext = {
agentId?: string;
sessionId: string;
};
// session_start hook
export type PluginHookSessionStartEvent = {
sessionId: string;
resumedFrom?: string;
};
// session_end hook
export type PluginHookSessionEndEvent = {
sessionId: string;
messageCount: number;
durationMs?: number;
};
// Gateway context
export type PluginHookGatewayContext = {
port?: number;
};
// gateway_start hook
export type PluginHookGatewayStartEvent = {
port: number;
};
// gateway_stop hook
export type PluginHookGatewayStopEvent = {
reason?: string;
};
// Hook handler types mapped by hook name
export type PluginHookHandlerMap = {
before_agent_start: (
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
before_compaction: (
event: PluginHookBeforeCompactionEvent,
ctx: PluginHookAgentContext,
) => Promise<void> | void;
after_compaction: (
event: PluginHookAfterCompactionEvent,
ctx: PluginHookAgentContext,
) => Promise<void> | void;
message_received: (
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,
) => Promise<void> | void;
message_sending: (
event: PluginHookMessageSendingEvent,
ctx: PluginHookMessageContext,
) => Promise<PluginHookMessageSendingResult | void> | PluginHookMessageSendingResult | void;
message_sent: (
event: PluginHookMessageSentEvent,
ctx: PluginHookMessageContext,
) => Promise<void> | void;
before_tool_call: (
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
) => Promise<PluginHookBeforeToolCallResult | void> | PluginHookBeforeToolCallResult | void;
after_tool_call: (
event: PluginHookAfterToolCallEvent,
ctx: PluginHookToolContext,
) => Promise<void> | void;
session_start: (
event: PluginHookSessionStartEvent,
ctx: PluginHookSessionContext,
) => Promise<void> | void;
session_end: (
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
) => Promise<void> | void;
gateway_start: (
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,
) => Promise<void> | void;
gateway_stop: (
event: PluginHookGatewayStopEvent,
ctx: PluginHookGatewayContext,
) => Promise<void> | void;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {
pluginId: string;
hookName: K;
handler: PluginHookHandlerMap[K];
priority?: number;
source: string;
};
@@ -228,7 +228,7 @@ describe("web auto-reply", () => {
await run; await run;
}, 15_000); }, 15_000);
it("stops after hitting max reconnect attempts", { timeout: 60_000 }, async () => { it("stops after hitting max reconnect attempts", { timeout: 20000 }, async () => {
const closeResolvers: Array<() => void> = []; const closeResolvers: Array<() => void> = [];
const sleep = vi.fn(async () => {}); const sleep = vi.fn(async () => {});
const listenerFactory = vi.fn(async () => { const listenerFactory = vi.fn(async () => {
+2 -2
View File
@@ -21,7 +21,7 @@ describe("web logout", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("deletes cached credentials when present", { timeout: 60_000 }, async () => { it("deletes cached credentials when present", { timeout: 15_000 }, async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.resetModules(); vi.resetModules();
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
@@ -37,7 +37,7 @@ describe("web logout", () => {
}); });
}); });
it("no-ops when nothing to delete", { timeout: 60_000 }, async () => { it("no-ops when nothing to delete", { timeout: 15_000 }, async () => {
await withTempHome(async () => { await withTempHome(async () => {
vi.resetModules(); vi.resetModules();
const { logoutWeb } = await import("./session.js"); const { logoutWeb } = await import("./session.js");
-1
View File
@@ -7,5 +7,4 @@ process.on("exit", cleanup);
afterEach(() => { afterEach(() => {
// Guard against leaked fake timers across test files/workers. // Guard against leaked fake timers across test files/workers.
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllEnvs();
}); });
+2 -5
View File
@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
@@ -12,10 +11,8 @@ export default defineConfig({
}, },
}, },
test: { test: {
testTimeout: 60_000, testTimeout: 30_000,
hookTimeout: 120_000, hookTimeout: 60_000,
pool: "forks",
maxWorkers: isCI ? 3 : 4,
include: [ include: [
"src/**/*.test.ts", "src/**/*.test.ts",
"extensions/**/*.test.ts", "extensions/**/*.test.ts",