219 lines
7.0 KiB
TypeScript
219 lines
7.0 KiB
TypeScript
import { Type } from "@sinclair/typebox";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { MemoryCitationsMode } from "../../config/types.memory.js";
|
|
import type { MemorySearchResult } from "../../memory/types.js";
|
|
import type { AnyAgentTool } from "./common.js";
|
|
import { resolveMemoryBackendConfig } from "../../memory/backend-config.js";
|
|
import { getMemorySearchManager } from "../../memory/index.js";
|
|
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
|
import { resolveMemorySearchConfig } from "../memory-search.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?: OpenClawConfig;
|
|
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:
|
|
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines.",
|
|
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 });
|
|
}
|
|
try {
|
|
const citationsMode = resolveMemoryCitationsMode(cfg);
|
|
const includeCitations = shouldIncludeCitations({
|
|
mode: citationsMode,
|
|
sessionKey: options.agentSessionKey,
|
|
});
|
|
const rawResults = await manager.search(query, {
|
|
maxResults,
|
|
minScore,
|
|
sessionKey: options.agentSessionKey,
|
|
});
|
|
const status = manager.status();
|
|
const decorated = decorateCitations(rawResults, includeCitations);
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const results =
|
|
status.backend === "qmd"
|
|
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
|
|
: decorated;
|
|
return jsonResult({
|
|
results,
|
|
provider: status.provider,
|
|
model: status.model,
|
|
fallback: status.fallback,
|
|
citations: citationsMode,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResult({ results: [], disabled: true, error: message });
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createMemoryGetTool(options: {
|
|
config?: OpenClawConfig;
|
|
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:
|
|
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
|
|
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 });
|
|
}
|
|
try {
|
|
const result = await manager.readFile({
|
|
relPath,
|
|
from: from ?? undefined,
|
|
lines: lines ?? undefined,
|
|
});
|
|
return jsonResult(result);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResult({ path: relPath, text: "", disabled: true, error: message });
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
|
|
const mode = cfg.memory?.citations;
|
|
if (mode === "on" || mode === "off" || mode === "auto") {
|
|
return mode;
|
|
}
|
|
return "auto";
|
|
}
|
|
|
|
function decorateCitations(results: MemorySearchResult[], include: boolean): MemorySearchResult[] {
|
|
if (!include) {
|
|
return results.map((entry) => ({ ...entry, citation: undefined }));
|
|
}
|
|
return results.map((entry) => {
|
|
const citation = formatCitation(entry);
|
|
const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`;
|
|
return { ...entry, citation, snippet };
|
|
});
|
|
}
|
|
|
|
function formatCitation(entry: MemorySearchResult): string {
|
|
const lineRange =
|
|
entry.startLine === entry.endLine
|
|
? `#L${entry.startLine}`
|
|
: `#L${entry.startLine}-L${entry.endLine}`;
|
|
return `${entry.path}${lineRange}`;
|
|
}
|
|
|
|
function clampResultsByInjectedChars(
|
|
results: MemorySearchResult[],
|
|
budget?: number,
|
|
): MemorySearchResult[] {
|
|
if (!budget || budget <= 0) {
|
|
return results;
|
|
}
|
|
let remaining = budget;
|
|
const clamped: MemorySearchResult[] = [];
|
|
for (const entry of results) {
|
|
if (remaining <= 0) {
|
|
break;
|
|
}
|
|
const snippet = entry.snippet ?? "";
|
|
if (snippet.length <= remaining) {
|
|
clamped.push(entry);
|
|
remaining -= snippet.length;
|
|
} else {
|
|
const trimmed = snippet.slice(0, Math.max(0, remaining));
|
|
clamped.push({ ...entry, snippet: trimmed });
|
|
break;
|
|
}
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
function shouldIncludeCitations(params: {
|
|
mode: MemoryCitationsMode;
|
|
sessionKey?: string;
|
|
}): boolean {
|
|
if (params.mode === "on") {
|
|
return true;
|
|
}
|
|
if (params.mode === "off") {
|
|
return false;
|
|
}
|
|
// auto: show citations in direct chats; suppress in groups/channels by default.
|
|
const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
|
|
return chatType === "direct";
|
|
}
|
|
|
|
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
|
|
const parsed = parseAgentSessionKey(sessionKey);
|
|
if (!parsed?.rest) {
|
|
return "direct";
|
|
}
|
|
const tokens = new Set(parsed.rest.toLowerCase().split(":").filter(Boolean));
|
|
if (tokens.has("channel")) {
|
|
return "channel";
|
|
}
|
|
if (tokens.has("group")) {
|
|
return "group";
|
|
}
|
|
return "direct";
|
|
}
|