Spaces:
Paused
Paused
| 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"; | |
| } | |