mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 20:20:52 +00:00
Multi-agent AI gateway with WebSocket RPC, HTTP API, and messaging channel integrations. Go port of OpenClaw with multi-tenant PostgreSQL, per-user isolation, security hardening, and production observability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
7.7 KiB
Go
254 lines
7.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// MemorySearchTool implements the memory_search tool for hybrid semantic + FTS search.
|
|
type MemorySearchTool struct {
|
|
manager *memory.Manager // standalone mode (SQLite-backed)
|
|
memStore store.MemoryStore // managed mode (Postgres-backed), nil in standalone
|
|
}
|
|
|
|
func NewMemorySearchTool(manager *memory.Manager) *MemorySearchTool {
|
|
return &MemorySearchTool{manager: manager}
|
|
}
|
|
|
|
// SetMemoryStore enables managed mode: queries go to Postgres with agentID/userID scoping.
|
|
func (t *MemorySearchTool) SetMemoryStore(ms store.MemoryStore) {
|
|
t.memStore = ms
|
|
}
|
|
|
|
func (t *MemorySearchTool) Name() string { return "memory_search" }
|
|
|
|
func (t *MemorySearchTool) Description() string {
|
|
return "Mandatory recall step: semantically search MEMORY.md + memory/*.md before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user. IMPORTANT: Always query in the SAME language as the stored memory content. If the user speaks Vietnamese, search in Vietnamese. If memory was written in English, search in English. Matching the language dramatically improves search accuracy."
|
|
}
|
|
|
|
func (t *MemorySearchTool) Parameters() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Natural language search query. Must be in the same language as the stored memory content (e.g., Vietnamese if memory is in Vietnamese).",
|
|
},
|
|
"maxResults": map[string]interface{}{
|
|
"type": "number",
|
|
"description": "Maximum number of results to return (default: 6)",
|
|
},
|
|
"minScore": map[string]interface{}{
|
|
"type": "number",
|
|
"description": "Minimum relevance score threshold (0-1)",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
}
|
|
}
|
|
|
|
func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interface{}) *Result {
|
|
query, _ := args["query"].(string)
|
|
if query == "" {
|
|
return ErrorResult("query parameter is required")
|
|
}
|
|
|
|
var maxResults int
|
|
var minScore float64
|
|
if mr, ok := args["maxResults"].(float64); ok {
|
|
maxResults = int(mr)
|
|
}
|
|
if ms, ok := args["minScore"].(float64); ok {
|
|
minScore = ms
|
|
}
|
|
|
|
// Managed mode: use MemoryStore with agentID/userID from context
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if t.memStore != nil && agentID != uuid.Nil {
|
|
return t.executeManaged(ctx, query, agentID.String(), maxResults, minScore)
|
|
}
|
|
|
|
// Standalone mode: use memory.Manager
|
|
if t.manager == nil {
|
|
return ErrorResult("memory system not available")
|
|
}
|
|
|
|
opts := memory.SearchOptions{
|
|
Query: query,
|
|
MaxResults: maxResults,
|
|
MinScore: minScore,
|
|
}
|
|
|
|
results, err := t.manager.Search(ctx, query, opts)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("memory search failed: %v", err))
|
|
}
|
|
if len(results) == 0 {
|
|
return NewResult("No memory results found for query: " + query)
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
|
"results": results,
|
|
"count": len(results),
|
|
}, "", " ")
|
|
return NewResult(string(data))
|
|
}
|
|
|
|
func (t *MemorySearchTool) executeManaged(ctx context.Context, query, agentID string, maxResults int, minScore float64) *Result {
|
|
userID := store.UserIDFromContext(ctx)
|
|
results, err := t.memStore.Search(ctx, query, agentID, userID, store.MemorySearchOptions{
|
|
MaxResults: maxResults,
|
|
MinScore: minScore,
|
|
})
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("memory search failed: %v", err))
|
|
}
|
|
if len(results) == 0 {
|
|
return NewResult("No memory results found for query: " + query)
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
|
"results": results,
|
|
"count": len(results),
|
|
}, "", " ")
|
|
return NewResult(string(data))
|
|
}
|
|
|
|
// MemoryGetTool implements the memory_get tool for reading specific memory files.
|
|
type MemoryGetTool struct {
|
|
manager *memory.Manager
|
|
memStore store.MemoryStore // managed mode (Postgres-backed), nil in standalone
|
|
}
|
|
|
|
func NewMemoryGetTool(manager *memory.Manager) *MemoryGetTool {
|
|
return &MemoryGetTool{manager: manager}
|
|
}
|
|
|
|
// SetMemoryStore enables managed mode: reads from Postgres memory_documents.
|
|
func (t *MemoryGetTool) SetMemoryStore(ms store.MemoryStore) {
|
|
t.memStore = ms
|
|
}
|
|
|
|
func (t *MemoryGetTool) Name() string { return "memory_get" }
|
|
|
|
func (t *MemoryGetTool) Description() string {
|
|
return "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."
|
|
}
|
|
|
|
func (t *MemoryGetTool) Parameters() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Relative path to memory file (e.g., 'MEMORY.md' or 'memory/notes.md')",
|
|
},
|
|
"from": map[string]interface{}{
|
|
"type": "number",
|
|
"description": "Start line number (1-indexed). Omit to read from beginning.",
|
|
},
|
|
"lines": map[string]interface{}{
|
|
"type": "number",
|
|
"description": "Number of lines to read. Omit to read entire file.",
|
|
},
|
|
},
|
|
"required": []string{"path"},
|
|
}
|
|
}
|
|
|
|
func (t *MemoryGetTool) Execute(ctx context.Context, args map[string]interface{}) *Result {
|
|
path, _ := args["path"].(string)
|
|
if path == "" {
|
|
return ErrorResult("path parameter is required")
|
|
}
|
|
|
|
var fromLine, numLines int
|
|
if from, ok := args["from"].(float64); ok {
|
|
fromLine = int(from)
|
|
}
|
|
if lines, ok := args["lines"].(float64); ok {
|
|
numLines = int(lines)
|
|
}
|
|
|
|
// Managed mode: read from MemoryStore
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if t.memStore != nil && agentID != uuid.Nil {
|
|
return t.executeManaged(ctx, path, agentID.String(), fromLine, numLines)
|
|
}
|
|
|
|
// Standalone mode
|
|
if t.manager == nil {
|
|
return ErrorResult("memory system not available")
|
|
}
|
|
|
|
text, err := t.manager.GetFile(path, fromLine, numLines)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read %s: %v", path, err))
|
|
}
|
|
if text == "" {
|
|
return NewResult(fmt.Sprintf("File %s is empty or the specified range has no content.", path))
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
|
"path": path,
|
|
"text": text,
|
|
}, "", " ")
|
|
return NewResult(string(data))
|
|
}
|
|
|
|
func (t *MemoryGetTool) executeManaged(ctx context.Context, path, agentID string, fromLine, numLines int) *Result {
|
|
userID := store.UserIDFromContext(ctx)
|
|
|
|
// Try per-user first, then global
|
|
content, err := t.memStore.GetDocument(ctx, agentID, userID, path)
|
|
if err != nil && userID != "" {
|
|
// Fallback to global
|
|
content, err = t.memStore.GetDocument(ctx, agentID, "", path)
|
|
}
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read %s: %v", path, err))
|
|
}
|
|
|
|
text := extractLines(content, fromLine, numLines)
|
|
if text == "" {
|
|
return NewResult(fmt.Sprintf("File %s is empty or the specified range has no content.", path))
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
|
"path": path,
|
|
"text": text,
|
|
}, "", " ")
|
|
return NewResult(string(data))
|
|
}
|
|
|
|
// extractLines extracts a range of lines from content.
|
|
// fromLine is 1-indexed. If 0, starts from beginning. If numLines is 0, returns all.
|
|
func extractLines(content string, fromLine, numLines int) string {
|
|
if fromLine <= 0 && numLines <= 0 {
|
|
return content
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
start := 0
|
|
if fromLine > 0 {
|
|
start = fromLine - 1
|
|
}
|
|
if start >= len(lines) {
|
|
return ""
|
|
}
|
|
|
|
end := len(lines)
|
|
if numLines > 0 && start+numLines < end {
|
|
end = start + numLines
|
|
}
|
|
|
|
return strings.Join(lines[start:end], "\n")
|
|
}
|