feat: add memory vector search
This commit is contained in:
@@ -22,6 +22,7 @@ type ResolvedAgentConfig = {
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
memorySearch?: AgentEntry["memorySearch"];
|
||||
humanDelay?: AgentEntry["humanDelay"];
|
||||
identity?: AgentEntry["identity"];
|
||||
groupChat?: AgentEntry["groupChat"];
|
||||
@@ -95,6 +96,7 @@ export function resolveAgentConfig(
|
||||
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
|
||||
model: typeof entry.model === "string" ? entry.model : undefined,
|
||||
memorySearch: entry.memorySearch,
|
||||
humanDelay: entry.humanDelay,
|
||||
identity: entry.identity,
|
||||
groupChat: entry.groupChat,
|
||||
|
||||
@@ -9,6 +9,10 @@ import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
} from "./tools/memory-tool.js";
|
||||
import { createMessageTool } from "./tools/message-tool.js";
|
||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
||||
@@ -43,6 +47,14 @@ export function createClawdbotTools(options?: {
|
||||
config: options?.config,
|
||||
agentDir: options?.agentDir,
|
||||
});
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
config: options?.config,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
});
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
config: options?.config,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
});
|
||||
const tools: AnyAgentTool[] = [
|
||||
createBrowserTool({
|
||||
defaultControlUrl: options?.browserControlUrl,
|
||||
@@ -89,6 +101,9 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
...(memorySearchTool && memoryGetTool
|
||||
? [memorySearchTool, memoryGetTool]
|
||||
: []),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
];
|
||||
|
||||
|
||||
56
src/agents/memory-search.test.ts
Normal file
56
src/agents/memory-search.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMemorySearchConfig } from "./memory-search.js";
|
||||
|
||||
describe("memory search config", () => {
|
||||
it("returns null when disabled", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: { enabled: true },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
memorySearch: { enabled: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("merges defaults and overrides", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
chunking: { tokens: 500, overlap: 100 },
|
||||
query: { maxResults: 4, minScore: 0.2 },
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
memorySearch: {
|
||||
chunking: { tokens: 320 },
|
||||
query: { maxResults: 8 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.provider).toBe("openai");
|
||||
expect(resolved?.model).toBe("text-embedding-3-small");
|
||||
expect(resolved?.chunking.tokens).toBe(320);
|
||||
expect(resolved?.chunking.overlap).toBe(100);
|
||||
expect(resolved?.query.maxResults).toBe(8);
|
||||
expect(resolved?.query.minScore).toBe(0.2);
|
||||
});
|
||||
});
|
||||
134
src/agents/memory-search.ts
Normal file
134
src/agents/memory-search.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
provider: "openai" | "local";
|
||||
fallback: "openai" | "none";
|
||||
model: string;
|
||||
local: {
|
||||
modelPath?: string;
|
||||
modelCacheDir?: string;
|
||||
};
|
||||
store: {
|
||||
driver: "sqlite";
|
||||
path: string;
|
||||
};
|
||||
chunking: {
|
||||
tokens: number;
|
||||
overlap: number;
|
||||
};
|
||||
sync: {
|
||||
onSessionStart: boolean;
|
||||
onSearch: boolean;
|
||||
watch: boolean;
|
||||
watchDebounceMs: number;
|
||||
intervalMinutes: number;
|
||||
};
|
||||
query: {
|
||||
maxResults: number;
|
||||
minScore: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "text-embedding-3-small";
|
||||
const DEFAULT_CHUNK_TOKENS = 400;
|
||||
const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||
const DEFAULT_MAX_RESULTS = 6;
|
||||
const DEFAULT_MIN_SCORE = 0.35;
|
||||
|
||||
function resolveStorePath(agentId: string, raw?: string): string {
|
||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`);
|
||||
if (!raw) return fallback;
|
||||
const withToken = raw.includes("{agentId}")
|
||||
? raw.replaceAll("{agentId}", agentId)
|
||||
: raw;
|
||||
return resolveUserPath(withToken);
|
||||
}
|
||||
|
||||
function mergeConfig(
|
||||
defaults: MemorySearchConfig | undefined,
|
||||
overrides: MemorySearchConfig | undefined,
|
||||
agentId: string,
|
||||
): ResolvedMemorySearchConfig {
|
||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
||||
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
|
||||
const local = {
|
||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||
modelCacheDir:
|
||||
overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||
};
|
||||
const store = {
|
||||
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
|
||||
path: resolveStorePath(
|
||||
agentId,
|
||||
overrides?.store?.path ?? defaults?.store?.path,
|
||||
),
|
||||
};
|
||||
const chunking = {
|
||||
tokens:
|
||||
overrides?.chunking?.tokens ??
|
||||
defaults?.chunking?.tokens ??
|
||||
DEFAULT_CHUNK_TOKENS,
|
||||
overlap:
|
||||
overrides?.chunking?.overlap ??
|
||||
defaults?.chunking?.overlap ??
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
};
|
||||
const sync = {
|
||||
onSessionStart:
|
||||
overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
|
||||
onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true,
|
||||
watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true,
|
||||
watchDebounceMs:
|
||||
overrides?.sync?.watchDebounceMs ??
|
||||
defaults?.sync?.watchDebounceMs ??
|
||||
DEFAULT_WATCH_DEBOUNCE_MS,
|
||||
intervalMinutes:
|
||||
overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
|
||||
};
|
||||
const query = {
|
||||
maxResults:
|
||||
overrides?.query?.maxResults ??
|
||||
defaults?.query?.maxResults ??
|
||||
DEFAULT_MAX_RESULTS,
|
||||
minScore:
|
||||
overrides?.query?.minScore ??
|
||||
defaults?.query?.minScore ??
|
||||
DEFAULT_MIN_SCORE,
|
||||
};
|
||||
|
||||
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
|
||||
const minScore = Math.max(0, Math.min(1, query.minScore));
|
||||
return {
|
||||
enabled,
|
||||
provider,
|
||||
fallback,
|
||||
model,
|
||||
local,
|
||||
store,
|
||||
chunking: { tokens: Math.max(1, chunking.tokens), overlap },
|
||||
sync,
|
||||
query: { ...query, minScore },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMemorySearchConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): ResolvedMemorySearchConfig | null {
|
||||
const defaults = cfg.agents?.defaults?.memorySearch;
|
||||
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
|
||||
const resolved = mergeConfig(defaults, overrides, agentId);
|
||||
if (!resolved.enabled) return null;
|
||||
return resolved;
|
||||
}
|
||||
@@ -222,6 +222,16 @@
|
||||
"title": "Session Status",
|
||||
"detailKeys": ["sessionKey", "model"]
|
||||
},
|
||||
"memory_search": {
|
||||
"emoji": "🧠",
|
||||
"title": "Memory Search",
|
||||
"detailKeys": ["query"]
|
||||
},
|
||||
"memory_get": {
|
||||
"emoji": "📓",
|
||||
"title": "Memory Get",
|
||||
"detailKeys": ["path", "from", "lines"]
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
|
||||
101
src/agents/tools/memory-tool.ts
Normal file
101
src/agents/tools/memory-tool.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { getMemorySearchManager } from "../../memory/index.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
import { resolveMemorySearchConfig } from "../memory-search.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
|
||||
const MemorySearchSchema = Type.Object({
|
||||
query: Type.String(),
|
||||
maxResults: Type.Optional(Type.Number()),
|
||||
minScore: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
const MemoryGetSchema = Type.Object({
|
||||
path: Type.String(),
|
||||
from: Type.Optional(Type.Number()),
|
||||
lines: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
export function createMemorySearchTool(options: {
|
||||
config?: ClawdbotConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
const cfg = options.config;
|
||||
if (!cfg) return null;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options.agentSessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
if (!resolveMemorySearchConfig(cfg, agentId)) return null;
|
||||
return {
|
||||
label: "Memory Search",
|
||||
name: "memory_search",
|
||||
description:
|
||||
"Search agent memory files (MEMORY.md + memory/*.md) using semantic vectors.",
|
||||
parameters: MemorySearchSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const maxResults = readNumberParam(params, "maxResults");
|
||||
const minScore = readNumberParam(params, "minScore");
|
||||
const { manager, error } = await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
if (!manager) {
|
||||
return jsonResult({ results: [], disabled: true, error });
|
||||
}
|
||||
const results = await manager.search(query, {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const status = manager.status();
|
||||
return jsonResult({
|
||||
results,
|
||||
provider: status.provider,
|
||||
model: status.model,
|
||||
fallback: status.fallback,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryGetTool(options: {
|
||||
config?: ClawdbotConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
const cfg = options.config;
|
||||
if (!cfg) return null;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options.agentSessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
if (!resolveMemorySearchConfig(cfg, agentId)) return null;
|
||||
return {
|
||||
label: "Memory Get",
|
||||
name: "memory_get",
|
||||
description: "Read a memory file by path (workspace-relative).",
|
||||
parameters: MemoryGetSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const relPath = readStringParam(params, "path", { required: true });
|
||||
const from = readNumberParam(params, "from", { integer: true });
|
||||
const lines = readNumberParam(params, "lines", { integer: true });
|
||||
const { manager, error } = await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
if (!manager) {
|
||||
return jsonResult({ path: relPath, text: "", disabled: true, error });
|
||||
}
|
||||
const result = await manager.readFile({
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
});
|
||||
return jsonResult(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user