From 82f710c61102dba3c7e5f08b7b0f6a3f95a65bb2 Mon Sep 17 00:00:00 2001 From: helal-muneer Date: Wed, 15 Apr 2026 23:52:07 +0200 Subject: [PATCH] feat: Priority 3 memory enhancements - H: Cross-agent shared memory scope (shared:) - I: Entity relationship layer (entity-graph.ts) - J: Memory confidence scoring (confidence-tracker.ts) - K: Proactive memory injection (proactive-injector.ts) New tools: memory_entities, memory_boost, memory_shared Config: scopes.shared, entityGraph.enabled, proactive.enabled --- index.ts | 46 +++++ openclaw.plugin.json | 88 ++++++++++ package-lock.json | 1 - src/confidence-tracker.ts | 111 ++++++++++++ src/entity-graph.ts | 250 +++++++++++++++++++++++++++ src/proactive-injector.ts | 158 +++++++++++++++++ src/scopes.ts | 20 ++- src/tools.ts | 190 +++++++++++++++++++- test/clawteam-scope.test.mjs | 2 +- test/scope-access-undefined.test.mjs | 2 + 10 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 src/confidence-tracker.ts create mode 100644 src/entity-graph.ts create mode 100644 src/proactive-injector.ts diff --git a/index.ts b/index.ts index 4baf40f9..3bd0a9c3 100644 --- a/index.ts +++ b/index.ts @@ -79,6 +79,9 @@ import { type AdmissionRejectionAuditEntry, } from "./src/admission-control.js"; import { analyzeIntent, applyCategoryBoost } from "./src/intent-analyzer.js"; +import { createEntityGraph, type EntityGraph } from "./src/entity-graph.js"; +import { createConfidenceTracker, type ConfidenceTracker } from "./src/confidence-tracker.js"; +import { createProactiveInjector, type ProactiveInjector } from "./src/proactive-injector.js"; // ============================================================================ // Configuration & Types @@ -183,6 +186,7 @@ interface PluginConfig { default?: string; definitions?: Record; agentAccess?: Record; + shared?: { enabled?: boolean; autoPromote?: boolean }; }; enableManagementTools?: boolean; sessionStrategy?: SessionStrategy; @@ -225,6 +229,13 @@ interface PluginConfig { skipLowValue?: boolean; maxExtractionsPerHour?: number; }; + entityGraph?: { enabled?: boolean }; + proactive?: { + enabled?: boolean; + staleMemoryDays?: number; + entityPrefetch?: boolean; + patternTriggers?: Record; + }; } type ReflectionThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; @@ -1690,6 +1701,14 @@ const memoryLanceDBProPlugin = { ); const scopeManager = createScopeManager(config.scopes); + // Configure shared scope based on config (default: enabled) + const sharedEnabled = config.scopes?.shared?.enabled !== false; + if (sharedEnabled && !scopeManager.getScopeDefinition("shared")) { + scopeManager.addScopeDefinition("shared", { + description: "Cross-agent shared knowledge — read by all agents, written only by explicit writes or dreaming engine", + }); + } + // ClawTeam integration: extend accessible scopes via env var const clawteamScopes = parseClawteamScopes(process.env.CLAWTEAM_MEMORY_SCOPE); if (clawteamScopes.length > 0) { @@ -2085,6 +2104,22 @@ const memoryLanceDBProPlugin = { ); }); + // ======================================================================== + // Initialize Priority 3 Enhancements + // ======================================================================== + + const entityGraph = createEntityGraph({ enabled: config.entityGraph?.enabled ?? false }); + const confidenceTracker = createConfidenceTracker({ enabled: true }); + const proactiveInjector = createProactiveInjector( + { retriever, entityGraph, scopeManager }, + { + enabled: config.proactive?.enabled ?? false, + staleMemoryDays: config.proactive?.staleMemoryDays ?? 7, + entityPrefetch: config.proactive?.entityPrefetch ?? true, + patternTriggers: config.proactive?.patternTriggers ?? {}, + }, + ); + // ======================================================================== // Markdown Mirror // ======================================================================== @@ -2106,6 +2141,8 @@ const memoryLanceDBProPlugin = { workspaceDir: getDefaultWorkspaceDir(), mdMirror, workspaceBoundary: config.workspaceBoundary, + entityGraph, + confidenceTracker, }, { enableManagementTools: config.enableManagementTools, @@ -2485,6 +2522,11 @@ const memoryLanceDBProPlugin = { }), ); + // Track confidence for auto-recalled memories + for (const item of selected) { + confidenceTracker.recordRecall(item.id); + } + const memoryContext = selected.map((item) => item.line).join("\n"); const injectedIds = selected.map((item) => item.id).join(",") || "(none)"; @@ -2755,6 +2797,10 @@ const memoryLanceDBProPlugin = { conversationText, sessionKey, { scope: defaultScope, scopeFilter: accessibleScopes }, ); + // Extract entities from conversation text into the entity graph + if (config.entityGraph?.enabled) { + entityGraph.addEntitiesAndRelationships(conversationText); + } // Charge rate limiter only after successful extraction extractionRateLimiter.recordExtraction(); if (stats.created > 0 || stats.merged > 0) { diff --git a/openclaw.plugin.json b/openclaw.plugin.json index bf274e03..9e1c5d02 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -853,6 +853,94 @@ "description": "Maximum number of auto-capture extractions allowed per hour" } } + }, + "scopes": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "type": "string", + "default": "global" + }, + "definitions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string" + } + } + } + }, + "agentAccess": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "shared": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable cross-agent shared memory scope (read by all agents)" + }, + "autoPromote": { + "type": "boolean", + "default": false, + "description": "Auto-promote memories accessed by 3+ agents to shared scope during dream cycle" + } + } + } + } + }, + "entityGraph": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable entity relationship extraction and graph" + } + } + }, + "proactive": { + "type": "object", + "additionalProperties": false, + "description": "Proactive memory injection alongside auto-recall", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable proactive memory injection" + }, + "staleMemoryDays": { + "type": "number", + "minimum": 1, + "maximum": 365, + "default": 7, + "description": "Days before a memory is considered stale for proactive injection" + }, + "entityPrefetch": { + "type": "boolean", + "default": true, + "description": "Pre-fetch related memories when user mentions a known entity" + }, + "patternTriggers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Map of regex patterns to search queries for proactive injection" + } + } } } }, diff --git a/package-lock.json b/package-lock.json index 96bfabe4..de165655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -233,7 +233,6 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", diff --git a/src/confidence-tracker.ts b/src/confidence-tracker.ts new file mode 100644 index 00000000..a72b7609 --- /dev/null +++ b/src/confidence-tracker.ts @@ -0,0 +1,111 @@ +/** + * Memory Confidence Scoring + * Tracks per-memory confidence based on recall/useful signals. + * Stores data in memory metadata — no separate table needed. + */ + +// ============================================================================ +// Types +// ============================================================================ + +export interface ConfidenceTrackerConfig { + enabled: boolean; + decayFactor: number; +} + +interface MemoryConfidenceState { + recallCount: number; + usefulCount: number; + decayBoost: number; + lastRecallAt: number; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +export interface ConfidenceTracker { + recordRecall(memoryId: string): void; + recordUseful(memoryId: string): void; + getConfidence(memoryId: string): number; + getTopConfident(limit: number): string[]; + getState(memoryId: string): MemoryConfidenceState | undefined; + reset(): void; +} + +export function createConfidenceTracker(config: ConfidenceTrackerConfig = { enabled: true, decayFactor: 0.95 }): ConfidenceTracker { + if (!config.enabled) { + return createNoopTracker(); + } + + const state = new Map(); + + return { + recordRecall(memoryId: string): void { + const existing = state.get(memoryId); + const now = Date.now(); + if (existing) { + existing.recallCount++; + existing.lastRecallAt = now; + // If recalled but not marked useful recently, decay slightly + existing.decayBoost = Math.max(0.5, existing.decayBoost * config.decayFactor); + } else { + state.set(memoryId, { + recallCount: 1, + usefulCount: 0, + decayBoost: 1.0, + lastRecallAt: now, + }); + } + }, + + recordUseful(memoryId: string): void { + const existing = state.get(memoryId); + if (existing) { + existing.usefulCount++; + // Restore decay boost on useful signal + existing.decayBoost = Math.min(1.0, existing.decayBoost + 0.1); + } else { + state.set(memoryId, { + recallCount: 0, + usefulCount: 1, + decayBoost: 1.0, + lastRecallAt: Date.now(), + }); + } + }, + + getConfidence(memoryId: string): number { + const s = state.get(memoryId); + if (!s) return 0; + return (s.usefulCount / Math.max(s.recallCount, 1)) * s.decayBoost; + }, + + getTopConfident(limit: number): string[] { + return Array.from(state.entries()) + .map(([id, s]) => ({ id, score: s.usefulCount / Math.max(s.recallCount, 1) * s.decayBoost })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(e => e.id); + }, + + getState(memoryId: string): MemoryConfidenceState | undefined { + return state.get(memoryId); + }, + + reset(): void { + state.clear(); + }, + }; +} + +function createNoopTracker(): ConfidenceTracker { + return { + recordRecall: () => {}, + recordUseful: () => {}, + getConfidence: () => 0, + getTopConfident: () => [], + getState: () => undefined, + reset: () => {}, + }; +} diff --git a/src/entity-graph.ts b/src/entity-graph.ts new file mode 100644 index 00000000..866991e5 --- /dev/null +++ b/src/entity-graph.ts @@ -0,0 +1,250 @@ +/** + * Entity Relationship Layer + * Extracts entities from memory text and stores relationships in LanceDB. + * Uses regex patterns + category heuristics (no LLM needed). + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type EntityCategory = "person" | "project" | "tool" | "location" | "preference" | "organization" | "other"; + +export interface Entity { + name: string; + category: EntityCategory; + normalized: string; +} + +export interface Relationship { + subject: string; + predicate: string; + object: string; + confidence: number; + lastSeen: number; + sourceMemoryId?: string; +} + +export interface EntityProfile { + name: string; + category: EntityCategory; + factCount: number; + relationships: Relationship[]; + firstSeen: number; + lastSeen: number; +} + +export interface EntityGraphConfig { + enabled: boolean; +} + +// ============================================================================ +// Entity Extraction (regex-based) +// ============================================================================ + +/** Common patterns for entity extraction */ +const ENTITY_PATTERNS: Array<{ pattern: RegExp; category: EntityCategory }> = [ + // Projects: words with underscores/hyphens, or camelCase (code-like) + { pattern: /\b([a-z][a-z0-9]*(?:[_-][a-z0-9]+)+)\b/gi, category: "project" }, + // Tools/frameworks: common known names + { pattern: /\b(React|Vue|Angular|Svelte|Next\.?js|Node\.?js|Python|TypeScript|JavaScript|Rust|Go|Docker|Kubernetes|Git|Linux|PostgreSQL|Redis|MongoDB|Elasticsearch|LanceDB|OpenAI|Anthropic|Claude|GPT)\b/gi, category: "tool" }, + // Locations: capitalized multi-word phrases (basic heuristic) + { pattern: /\b((?:San Francisco|New York|London|Tokyo|Istanbul|Berlin|Paris|Dubai|Munich|Amsterdam|Singapore|Hong Kong|Toronto|Sydney|Los Angeles|Chicago|Seattle|Austin|Miami))\b/gi, category: "location" }, + // Organizations: common suffixes + { pattern: /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*(?: Inc| LLC| Ltd| Corp| GmbH| AG| Co| Company| Labs| Foundation| Institute))\b/g, category: "organization" }, + // Preferences: "prefers X", "likes X", "doesn't like X" + { pattern: /\b(prefers?|likes?|dislikes?|loves?|hates?|enjoys?|avoids?)\s+([a-zA-Z][\w\s]{2,30}?)\b/gi, category: "preference" }, +]; + +/** Extract entities from text using regex patterns */ +export function extractEntities(text: string): Entity[] { + const seen = new Map(); + + for (const { pattern, category } of ENTITY_PATTERNS) { + // Reset lastIndex for global regexes + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const raw = match[0].trim(); + const name = match[1] ? match[1].trim() : raw; + if (!name || name.length < 2 || name.length > 60) continue; + + const normalized = name.toLowerCase(); + if (seen.has(normalized)) continue; + + seen.set(normalized, { name, category, normalized }); + } + } + + return Array.from(seen.values()); +} + +/** Extract relationships from text */ +export function extractRelationships(text: string, memoryId?: string): Relationship[] { + const relationships: Relationship[] = []; + const now = Date.now(); + + // Pattern: "X maintains/uses/works on/develops/leads Y" + const actionPatterns = [ + { re: /(\w+(?:\s\w+)?)\s+(maintains|uses|works on|develops|leads|manages|created|built|owns|runs|contributes to)\s+([a-zA-Z][\w\s]{2,40}?)\b/gi, pred: (m: RegExpExecArray) => m[2].toLowerCase() }, + { re: /(\w+(?:\s\w+)?)\s+(is part of|works at|works for|belongs to|joined|left)\s+([a-zA-Z][\w\s]{2,40}?)\b/gi, pred: (m: RegExpExecArray) => m[2].toLowerCase() }, + { re: /(\w+(?:\s\w+)?)\s+(prefers|likes|dislikes|chose|switched to)\s+([a-zA-Z][\w\s]{2,40}?)\b/gi, pred: (m: RegExpExecArray) => m[2].toLowerCase() }, + ]; + + for (const { re, pred } of actionPatterns) { + re.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + relationships.push({ + subject: match[1].trim(), + predicate: pred(match), + object: match[3].trim(), + confidence: 0.7, + lastSeen: now, + sourceMemoryId: memoryId, + }); + } + } + + return relationships; +} + +// ============================================================================ +// Entity Graph (in-memory + LanceDB-backed) +// ============================================================================ + +export interface EntityGraph { + extractEntities(text: string): Entity[]; + addRelationship(rel: Relationship): void; + addEntitiesAndRelationships(text: string, memoryId?: string): void; + getRelated(entity: string, depth?: number): Relationship[]; + getEntityProfile(name: string): EntityProfile; + getAllEntities(): Entity[]; + getStats(): { entityCount: number; relationshipCount: number }; +} + +/** + * In-memory entity graph implementation. + * LanceDB persistence can be added later if needed for durability across restarts. + */ +export function createEntityGraph(config: EntityGraphConfig = { enabled: true }): EntityGraph { + if (!config.enabled) { + return createNoopEntityGraph(); + } + + const entities = new Map(); + const relationships = new Map(); + const entityTimestamps = new Map(); + + function getRelKey(subject: string, predicate: string, object: string): string { + return `${subject.toLowerCase()}::${predicate.toLowerCase()}::${object.toLowerCase()}`; + } + + function addRelationship(rel: Relationship): void { + const key = getRelKey(rel.subject, rel.predicate, rel.object); + const existing = relationships.get(key); + if (existing) { + existing[0].confidence = Math.min(1, existing[0].confidence + 0.05); + existing[0].lastSeen = rel.lastSeen; + if (rel.sourceMemoryId) existing[0].sourceMemoryId = rel.sourceMemoryId; + } else { + relationships.set(key, [rel]); + } + + // Track entity timestamps + const now = rel.lastSeen; + for (const name of [rel.subject, rel.object]) { + const normalized = name.toLowerCase(); + const ts = entityTimestamps.get(normalized); + if (ts) { + ts.lastSeen = now; + } else { + entityTimestamps.set(normalized, { firstSeen: now, lastSeen: now }); + } + } + } + + return { + extractEntities, + + addRelationship, + + addEntitiesAndRelationships(text: string, memoryId?: string): void { + const extracted = extractEntities(text); + for (const entity of extracted) { + if (!entities.has(entity.normalized)) { + entities.set(entity.normalized, entity); + } + } + const rels = extractRelationships(text, memoryId); + for (const rel of rels) { + addRelationship(rel); + } + }, + + getRelated(entity: string, depth = 1): Relationship[] { + const normalized = entity.toLowerCase(); + const visited = new Set(); + const result: Relationship[] = []; + + function collect(name: string, currentDepth: number): void { + if (currentDepth > depth) return; + for (const [, rels] of relationships) { + for (const rel of rels) { + const key = getRelKey(rel.subject, rel.predicate, rel.object); + if (visited.has(key)) continue; + if (rel.subject.toLowerCase() === name || rel.object.toLowerCase() === name) { + visited.add(key); + result.push(rel); + // Recurse to the "other side" + const next = rel.subject.toLowerCase() === name ? rel.object : rel.subject; + collect(next, currentDepth + 1); + } + } + } + } + + collect(normalized, 0); + return result; + }, + + getEntityProfile(name: string): EntityProfile { + const normalized = name.toLowerCase(); + const entity = entities.get(normalized); + const ts = entityTimestamps.get(normalized); + const rels = this.getRelated(name, 1); + + return { + name: entity?.name ?? name, + category: entity?.category ?? "other", + factCount: rels.length, + relationships: rels, + firstSeen: ts?.firstSeen ?? Date.now(), + lastSeen: ts?.lastSeen ?? Date.now(), + }; + }, + + getAllEntities(): Entity[] { + return Array.from(entities.values()); + }, + + getStats(): { entityCount: number; relationshipCount: number } { + return { + entityCount: entities.size, + relationshipCount: relationships.size, + }; + }, + }; +} + +function createNoopEntityGraph(): EntityGraph { + return { + extractEntities: () => [], + addRelationship: () => {}, + addEntitiesAndRelationships: () => {}, + getRelated: () => [], + getEntityProfile: (name: string) => ({ name, category: "other", factCount: 0, relationships: [], firstSeen: 0, lastSeen: 0 }), + getAllEntities: () => [], + getStats: () => ({ entityCount: 0, relationshipCount: 0 }), + }; +} diff --git a/src/proactive-injector.ts b/src/proactive-injector.ts new file mode 100644 index 00000000..10477a0f --- /dev/null +++ b/src/proactive-injector.ts @@ -0,0 +1,158 @@ +/** + * Proactive Memory Injection + * Hooks into before_prompt_build alongside auto-recall to inject contextually + * relevant memories based on staleness, entity mentions, and pattern triggers. + */ + +import type { MemoryRetriever } from "./retriever.js"; +import type { EntityGraph } from "./entity-graph.js"; +import { resolveScopeFilter } from "./scopes.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProactiveConfig { + enabled: boolean; + staleMemoryDays: number; + entityPrefetch: boolean; + patternTriggers: Record; // regex pattern → search query +} + +interface ProactiveContext { + retriever: MemoryRetriever; + entityGraph: EntityGraph; + scopeManager: { getScopeFilter?: (agentId?: string) => string[] | undefined; getAccessibleScopes: (agentId?: string) => string[] }; +} + +export interface ProactiveResult { + injected: boolean; + reason: string; + memoryIds: string[]; + text: string; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +export interface ProactiveInjector { + /** + * Attempt a proactive injection. Returns null if nothing to inject. + * @param userMessage The user's latest message + * @param agentId Current agent ID + * @param existingRecallIds IDs already returned by auto-recall (for dedup) + */ + tryInject(userMessage: string, agentId: string | undefined, existingRecallIds: string[]): Promise; +} + +export function createProactiveInjector( + context: ProactiveContext, + config: ProactiveConfig, +): ProactiveInjector { + if (!config.enabled) { + return { tryInject: async () => null }; + } + + const seenStale = new Set(); + + return { + async tryInject(userMessage: string, agentId: string | undefined, existingRecallIds: string[]): Promise { + // Max 1 proactive injection per turn + const scopeFilter = context.scopeManager.getScopeFilter + ? context.scopeManager.getScopeFilter(agentId) + : context.scopeManager.getAccessibleScopes(agentId); + + // 1. Entity-based prefetch + if (config.entityPrefetch) { + const entities = context.entityGraph.extractEntities(userMessage); + for (const entity of entities.slice(0, 3)) { + const profile = context.entityGraph.getEntityProfile(entity.name); + // Only prefetch if entity has meaningful relationships + if (profile.factCount > 0 && profile.relationships.length > 0) { + // Build query from relationship objects + const relatedNames = profile.relationships + .map(r => r.subject === entity.name ? r.object : r.subject) + .filter(n => n.toLowerCase() !== entity.normalized) + .slice(0, 3); + + if (relatedNames.length === 0) continue; + + const query = `${entity.name} ${relatedNames.join(" ")}`; + try { + const results = await context.retriever.retrieve({ + query, + limit: 2, + scopeFilter, + }); + + const novel = results.filter(r => !existingRecallIds.includes(r.entry.id)); + if (novel.length > 0) { + const text = novel.map(r => r.entry.text.slice(0, 200)).join("\n"); + return { + injected: true, + reason: `entity-prefetch:${entity.name}`, + memoryIds: novel.map(r => r.entry.id), + text: `[Proactive: related to "${entity.name}"]\n${text}`, + }; + } + } catch { + // Silently skip on errors + } + } + } + } + + // 2. Pattern triggers + for (const [pattern, searchQuery] of Object.entries(config.patternTriggers)) { + try { + if (new RegExp(pattern, "i").test(userMessage)) { + const results = await context.retriever.retrieve({ + query: searchQuery, + limit: 1, + scopeFilter, + }); + const novel = results.filter(r => !existingRecallIds.includes(r.entry.id)); + if (novel.length > 0) { + return { + injected: true, + reason: `pattern-trigger:${pattern}`, + memoryIds: novel.map(r => r.entry.id), + text: novel[0].entry.text.slice(0, 300), + }; + } + } + } catch { + // Invalid regex or retrieval error — skip + } + } + + // 3. Stale memory check (only occasionally, not every turn) + if (Math.random() < 0.05) { // ~5% chance per turn + try { + const staleResults = await context.retriever.retrieve({ + query: userMessage, + limit: 1, + scopeFilter, + }); + for (const r of staleResults) { + const ageDays = (Date.now() - r.entry.timestamp) / (1000 * 60 * 60 * 24); + if (ageDays > config.staleMemoryDays && !seenStale.has(r.entry.id) && !existingRecallIds.includes(r.entry.id)) { + seenStale.add(r.entry.id); + return { + injected: true, + reason: `stale-memory:${Math.round(ageDays)}d`, + memoryIds: [r.entry.id], + text: `[Proactive: memory not revisited in ${Math.round(ageDays)} days]\n${r.entry.text.slice(0, 200)}`, + }; + } + } + } catch { + // Skip + } + } + + return null; + }, + }; +} diff --git a/src/scopes.ts b/src/scopes.ts index 5e3e1071..3603d2fe 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -51,6 +51,9 @@ export const DEFAULT_SCOPE_CONFIG: ScopeConfig = { global: { description: "Shared knowledge across all agents", }, + shared: { + description: "Cross-agent shared knowledge — read by all agents, written only by dreaming engine or explicit writes", + }, }, agentAccess: {}, }; @@ -61,6 +64,7 @@ export const DEFAULT_SCOPE_CONFIG: ScopeConfig = { const SCOPE_PATTERNS = { GLOBAL: "global", + SHARED: "shared", AGENT: (agentId: string) => `agent:${agentId}`, CUSTOM: (name: string) => `custom:${name}`, REFLECTION: (agentId: string) => `reflection:agent:${agentId}`, @@ -68,6 +72,11 @@ const SCOPE_PATTERNS = { USER: (userId: string) => `user:${userId}`, }; +/** Check if a scope string is the shared scope. */ +export function isSharedScope(scope: string): boolean { + return scope === "shared"; +} + const SYSTEM_BYPASS_IDS = new Set(["system", "undefined"]); const warnedLegacyFallbackBypassIds = new Set(); @@ -177,6 +186,7 @@ export class MemoryScopeManager implements ScopeManager { private isBuiltInScope(scope: string): boolean { return ( scope === "global" || + scope === "shared" || scope.startsWith("agent:") || scope.startsWith("custom:") || scope.startsWith("project:") || @@ -200,10 +210,16 @@ export class MemoryScopeManager implements ScopeManager { } // Agent and reflection scopes are built-in and provisioned implicitly. - return withOwnReflectionScope([ + // Shared scope is included for all agents when enabled (default). + const scopes = [ "global", SCOPE_PATTERNS.AGENT(normalizedAgentId), - ], normalizedAgentId); + ]; + // Check if shared scope is enabled (read from definitions — if "shared" is defined, it's enabled) + if (this.config.definitions["shared"]) { + scopes.push("shared"); + } + return withOwnReflectionScope(scopes, normalizedAgentId); } /** diff --git a/src/tools.ts b/src/tools.ts index 6b6e1beb..34403119 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -2190,9 +2190,190 @@ export function registerMemoryExplainRankTool( // Tool Registration Helper // ============================================================================ -export function registerAllMemoryTools( +// ============================================================================ +// Entity Graph Tool +// ============================================================================ + +export function registerMemoryEntitiesTool( + api: OpenClawPluginApi, + context: ToolContext & { entityGraph?: { extractEntities(text: string): Array<{ name: string; category: string; normalized: string }>; getRelated(entity: string, depth?: number): Array<{ subject: string; predicate: string; object: string; confidence: number; lastSeen: number }>; getEntityProfile(name: string): { name: string; category: string; factCount: number; relationships: unknown[]; firstSeen: number; lastSeen: number }; getAllEntities(): Array<{ name: string; category: string; normalized: string }> }; }, +) { + api.registerTool( + (toolCtx) => { + const runtimeContext = resolveToolContext(context, toolCtx); + return { + name: "memory_entities", + label: "Memory Entities", + description: "Query the entity graph for relationships and profiles about known entities (people, projects, tools, locations).", + parameters: Type.Object({ + entity: Type.String({ description: "Entity name to look up" }), + action: Type.Optional(stringEnum(["profile", "related"] as const)), + depth: Type.Optional(Type.Number({ description: "Relationship traversal depth (default: 1)" })), + }), + async execute(_toolCallId, params) { + const { entity, action = "profile", depth = 1 } = params as { entity: string; action?: "profile" | "related"; depth?: number }; + + if (!context.entityGraph) { + return { content: [{ type: "text", text: "Entity graph is not enabled." }], details: { error: "disabled" } }; + } + + if (action === "related") { + const rels = context.entityGraph.getRelated(entity, depth); + if (rels.length === 0) { + return { content: [{ type: "text", text: `No relationships found for "${entity}".` }], details: { count: 0 } }; + } + const text = rels.map(r => `- ${r.subject} ${r.predicate} ${r.object} (confidence: ${r.confidence.toFixed(2)})`).join("\n"); + return { content: [{ type: "text", text: `Relationships for "${entity}":\n${text}` }], details: { count: rels.length, relationships: rels } }; + } + + const profile = context.entityGraph.getEntityProfile(entity); + const relLines = profile.relationships.slice(0, 10).map((r: any) => `- ${r.subject} ${r.predicate} ${r.object}`).join("\n"); + const text = [ + `Entity: ${profile.name}`, + `Category: ${profile.category}`, + `Known facts: ${profile.factCount}`, + `First seen: ${profile.firstSeen ? new Date(profile.firstSeen).toISOString().split("T")[0] : "unknown"}`, + `Last seen: ${profile.lastSeen ? new Date(profile.lastSeen).toISOString().split("T")[0] : "unknown"}`, + profile.relationships.length > 0 ? `Relationships:\n${relLines}` : "No relationships yet.", + ].join("\n"); + return { content: [{ type: "text", text }], details: { profile } }; + }, + }; + }, + { name: "memory_entities" }, + ); +} + +// ============================================================================ +// Confidence Boost Tool +// ============================================================================ + +export function registerMemoryBoostTool( + api: OpenClawPluginApi, + context: ToolContext & { confidenceTracker?: { recordUseful(memoryId: string): void; getConfidence(memoryId: string): number } }, +) { + api.registerTool( + (toolCtx) => { + const runtimeContext = resolveToolContext(context, toolCtx); + return { + name: "memory_boost", + label: "Memory Boost", + description: "Manually boost a memory's confidence score, signaling it was useful in a response.", + parameters: Type.Object({ + memoryId: Type.Optional(Type.String({ description: "Memory ID to boost (UUID or prefix)" })), + query: Type.Optional(Type.String({ description: "Search query to find memory when memoryId is omitted" })), + }), + async execute(_toolCallId, params) { + const { memoryId, query } = params as { memoryId?: string; query?: string }; + if (!memoryId && !query) { + return { content: [{ type: "text", text: "Provide memoryId or query." }], details: { error: "missing_param" } }; + } + + if (!context.confidenceTracker) { + return { content: [{ type: "text", text: "Confidence tracking is not enabled." }], details: { error: "disabled" } }; + } + + const agentId = runtimeContext.agentId; + const scopeFilter = resolveScopeFilter(runtimeContext.scopeManager, agentId); + const resolved = await resolveMemoryId(runtimeContext, memoryId ?? query ?? "", scopeFilter); + if (!resolved.ok) { + return { content: [{ type: "text", text: resolved.message }], details: resolved.details ?? { error: "resolve_failed" } }; + } + + context.confidenceTracker.recordUseful(resolved.id); + const confidence = context.confidenceTracker.getConfidence(resolved.id); + return { + content: [{ type: "text", text: `Boosted memory ${resolved.id.slice(0, 8)}... confidence: ${confidence.toFixed(3)}` }], + details: { action: "boosted", id: resolved.id, confidence }, + }; + }, + }; + }, + { name: "memory_boost" }, + ); +} + +// ============================================================================ +// Shared Memory Write Tool +// ============================================================================ + +export function registerMemorySharedTool( api: OpenClawPluginApi, context: ToolContext, +) { + api.registerTool( + (toolCtx) => { + const runtimeContext = resolveToolContext(context, toolCtx); + return { + name: "memory_shared", + label: "Memory Shared", + description: "Explicitly write a memory to the shared scope, making it accessible to all agents.", + parameters: Type.Object({ + text: Type.String({ description: "Information to store in shared scope" }), + importance: Type.Optional(Type.Number({ description: "Importance score 0-1 (default: 0.7)" })), + category: Type.Optional(stringEnum(MEMORY_CATEGORIES)), + }), + async execute(_toolCallId, params) { + const { text, importance = 0.7, category = "fact" } = params as { text: string; importance?: number; category?: string }; + const agentId = runtimeContext.agentId; + + // Validate shared scope is accessible + if (!runtimeContext.scopeManager.isAccessible("shared", agentId)) { + return { content: [{ type: "text", text: "Shared scope is not enabled or not accessible." }], details: { error: "scope_access_denied", requestedScope: "shared" } }; + } + + // Noise check + if (isNoise(text)) { + return { content: [{ type: "text", text: "Skipped: text detected as noise." }], details: { action: "noise_filtered" } }; + } + + const stripped = stripEnvelopeMetadata(text); + if (!stripped.trim()) { + return { content: [{ type: "text", text: "Skipped: no extractable content." }], details: { action: "envelope_metadata_rejected" } }; + } + + const safeImportance = clamp01(importance, 0.7); + const vector = await runtimeContext.embedder.embedPassage(stripped); + + const entry = await runtimeContext.store.store({ + text: stripped, + vector, + importance: safeImportance, + category: category as any, + scope: "shared", + metadata: stringifySmartMetadata( + buildSmartMetadata( + { text: stripped, category: category as any, importance: safeImportance }, + { l0_abstract: stripped, l1_overview: `- ${stripped}`, l2_content: stripped, source: "manual_shared", state: "confirmed", memory_layer: "durable", last_confirmed_use_at: Date.now(), bad_recall_count: 0, suppressed_until_turn: 0 }, + ), + ), + }); + + if (context.mdMirror) { + await context.mdMirror({ text: stripped, category: category as string, scope: "shared", timestamp: entry.timestamp }, { source: "memory_shared", agentId }); + } + + return { + content: [{ type: "text", text: `Stored to shared scope: "${stripped.slice(0, 100)}${stripped.length > 100 ? "..." : ""}" (${entry.id.slice(0, 8)})` }], + details: { action: "created", id: entry.id, scope: "shared", category }, + }; + }, + }; + }, + { name: "memory_shared" }, + ); +} + +// ============================================================================ +// Tool Registration Helper +// ============================================================================ + +export function registerAllMemoryTools( + api: OpenClawPluginApi, + context: ToolContext & { + entityGraph?: { extractEntities(text: string): Array<{ name: string; category: string; normalized: string }>; getRelated(entity: string, depth?: number): Array<{ subject: string; predicate: string; object: string; confidence: number; lastSeen: number }>; getEntityProfile(name: string): { name: string; category: string; factCount: number; relationships: unknown[]; firstSeen: number; lastSeen: number }; getAllEntities(): Array<{ name: string; category: string; normalized: string }> }; + confidenceTracker?: { recordUseful(memoryId: string): void; getConfidence(memoryId: string): number }; + }, options: { enableManagementTools?: boolean; enableSelfImprovementTools?: boolean; @@ -2204,6 +2385,13 @@ export function registerAllMemoryTools( registerMemoryForgetTool(api, context); registerMemoryUpdateTool(api, context); + // Entity graph tool (always registered; returns "disabled" if not configured) + registerMemoryEntitiesTool(api, context); + // Confidence boost tool (always registered; returns "disabled" if not configured) + registerMemoryBoostTool(api, context); + // Shared memory write tool + registerMemorySharedTool(api, context); + // Management tools (optional) if (options.enableManagementTools) { registerMemoryStatsTool(api, context); diff --git a/test/clawteam-scope.test.mjs b/test/clawteam-scope.test.mjs index 14759394..a8f81020 100644 --- a/test/clawteam-scope.test.mjs +++ b/test/clawteam-scope.test.mjs @@ -122,7 +122,7 @@ describe("ClawTeam Scope Integration", () => { it("agent does not have team scopes by default", () => { const scopes = manager.getAccessibleScopes("main"); assert.ok(!scopes.includes("custom:team-demo"), "should NOT include team scope"); - assert.deepStrictEqual(scopes, ["global", "agent:main", "reflection:agent:main"]); + assert.deepStrictEqual(scopes, ["global", "agent:main", "shared", "reflection:agent:main"]); }); }); }); diff --git a/test/scope-access-undefined.test.mjs b/test/scope-access-undefined.test.mjs index ddcf674e..4c1b83e4 100644 --- a/test/scope-access-undefined.test.mjs +++ b/test/scope-access-undefined.test.mjs @@ -58,6 +58,7 @@ describe("MemoryScopeManager - System & Reflection Scopes", () => { assert.deepStrictEqual(manager.getScopeFilter("main"), [ "global", "agent:main", + "shared", "reflection:agent:main", ]); }); @@ -155,6 +156,7 @@ describe("MemoryScopeManager - System & Reflection Scopes", () => { assert.deepStrictEqual(manager.getAccessibleScopes("main"), [ "global", "agent:main", + "shared", "reflection:agent:main", ]); assert.strictEqual(manager.getDefaultScope("main"), "agent:main");