diff --git a/AGENTS.md b/AGENTS.md index 0330b14f..ae0d577f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.9.16) - 53 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 125 REST endpoints +- 126 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 4 skills - 50+ iii functions diff --git a/README.md b/README.md index 862e540f..d1eaa35f 100644 --- a/README.md +++ b/README.md @@ -1429,7 +1429,7 @@ Create `~/.agentmemory/.env`:

API

-125 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers. +126 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints diff --git a/src/config.ts b/src/config.ts index 1fe70465..b4f8979d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -375,6 +375,20 @@ export function getConsolidationDecayDays(): number { return safeParseInt(getMergedEnv()["CONSOLIDATION_DECAY_DAYS"], 30); } +export function isHighOrderSearchEnabled(): boolean { + const env = getMergedEnv(); + const explicit = env["AGENTMEMORY_HIGH_ORDER_SEARCH"]; + if (explicit === "false" || explicit === "0") return false; + if (explicit === "true" || explicit === "1") return true; + return isConsolidationEnabled(); +} + +export function getHighOrderConfidenceFloor(): number { + const env = getMergedEnv(); + const raw = parseFloat(env["AGENTMEMORY_SMART_SEARCH_CONFIDENCE_FLOOR"] || "0.3"); + return Number.isFinite(raw) && raw >= 0 && raw <= 1 ? raw : 0.3; +} + export function isStandaloneMcp(): boolean { return getMergedEnv()["STANDALONE_MCP"] === "true"; } diff --git a/src/functions/consolidation-pipeline.ts b/src/functions/consolidation-pipeline.ts index e51ab97c..39750101 100644 --- a/src/functions/consolidation-pipeline.ts +++ b/src/functions/consolidation-pipeline.ts @@ -17,7 +17,8 @@ import { import { recordAudit } from "./audit.js"; import { getConsolidationDecayDays, isConsolidationEnabled } from "../config.js"; import { logger } from "../logger.js"; - +import { getEmbeddingProvider } from "./search.js"; +import { float32ToBase64 } from "../state/vector-index.js"; function applyDecay( items: Array<{ strength: number; @@ -93,6 +94,8 @@ export function registerConsolidationPipelineFunction( const confidence = Number.isNaN(parsedConf) ? 0.5 : parsedConf; const fact = match[2].trim(); + const ep = getEmbeddingProvider(); + const existing = existingSemantic.find( (s) => s.fact.toLowerCase() === fact.toLowerCase(), ); @@ -101,6 +104,14 @@ export function registerConsolidationPipelineFunction( existing.lastAccessedAt = now; existing.updatedAt = now; existing.confidence = Math.max(existing.confidence, confidence); + if (!existing.embedding && ep) { + try { + existing.embedding = float32ToBase64(await ep.embed(existing.fact)); + existing.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed existing semantic fact", { id: existing.id, error: String(e) }); + } + } await kv.set(KV.semantic, existing.id, existing); } else { const sem: SemanticMemory = { @@ -115,6 +126,14 @@ export function registerConsolidationPipelineFunction( createdAt: now, updatedAt: now, }; + if (ep) { + try { + sem.embedding = float32ToBase64(await ep.embed(sem.fact)); + sem.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed new semantic fact", { error: String(e) }); + } + } await kv.set(KV.semantic, sem.id, sem); newFacts++; } @@ -187,6 +206,8 @@ export function registerConsolidationPipelineFunction( steps.push(stepMatch[1].trim()); } + const ep = getEmbeddingProvider(); + const existing = existingProcs.find( (p) => p.name.toLowerCase() === name.toLowerCase(), ); @@ -194,6 +215,15 @@ export function registerConsolidationPipelineFunction( existing.frequency++; existing.updatedAt = now; existing.strength = Math.min(1, existing.strength + 0.1); + if (!existing.embedding && ep) { + try { + const text = `${existing.name} ${existing.triggerCondition} ${existing.steps.join(" ")}`; + existing.embedding = float32ToBase64(await ep.embed(text)); + existing.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed existing procedural skill", { id: existing.id, error: String(e) }); + } + } await kv.set(KV.procedural, existing.id, existing); } else { const proc: ProceduralMemory = { @@ -207,6 +237,15 @@ export function registerConsolidationPipelineFunction( createdAt: now, updatedAt: now, }; + if (ep) { + try { + const text = `${proc.name} ${proc.triggerCondition} ${proc.steps.join(" ")}`; + proc.embedding = float32ToBase64(await ep.embed(text)); + proc.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed new procedural skill", { error: String(e) }); + } + } await kv.set(KV.procedural, proc.id, proc); newProcs++; } diff --git a/src/functions/crystallize.ts b/src/functions/crystallize.ts index 1f0b8431..a950c9c7 100644 --- a/src/functions/crystallize.ts +++ b/src/functions/crystallize.ts @@ -3,6 +3,10 @@ import type { StateKV } from "../state/kv.js"; import { KV, generateId } from "../state/schema.js"; import type { Action, ActionEdge, Crystal, MemoryProvider } from "../types.js"; +import { getEmbeddingProvider } from "./search.js"; +import { float32ToBase64 } from "../state/vector-index.js"; +import { logger } from "../logger.js"; + interface CrystalDigest { narrative: string; keyOutcomes: string[]; @@ -52,6 +56,7 @@ export function registerCrystallizeFunction( ); const prompt = buildChainText(actions, relevantEdges); + const ep = getEmbeddingProvider(); try { const response = await provider.summarize(CRYSTALLIZE_SYSTEM, prompt); @@ -69,6 +74,16 @@ export function registerCrystallizeFunction( createdAt: new Date().toISOString(), }; + if (ep) { + try { + const text = `${crystal.narrative} ${crystal.lessons.join(" ")}`; + crystal.embedding = float32ToBase64(await ep.embed(text)); + crystal.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed new crystal", { error: String(e) }); + } + } + await kv.set(KV.crystals, crystal.id, crystal); await Promise.all( diff --git a/src/functions/high-order-backfill.ts b/src/functions/high-order-backfill.ts new file mode 100644 index 00000000..2b919a85 --- /dev/null +++ b/src/functions/high-order-backfill.ts @@ -0,0 +1,122 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { SemanticMemory, ProceduralMemory, Crystal, Insight } from "../types.js"; +import { getEmbeddingProvider } from "./search.js"; +import { float32ToBase64 } from "../state/vector-index.js"; +import { logger } from "../logger.js"; + +const BACKFILL_BATCH_SIZE = 20; + +export function registerHighOrderBackfillFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::backfill-embeddings::high-order", async () => { + const ep = getEmbeddingProvider(); + if (!ep) { + return { success: false, error: "No embedding provider available" }; + } + + const results = { + semantic: 0, + procedural: 0, + crystals: 0, + insights: 0, + }; + + try { + // 1. Semantic Facts + const semantics = await kv.list(KV.semantic); + const semToUpdate = semantics.filter( + (s) => !s.embedding || s.embeddingModel !== ep.name + ); + for (let i = 0; i < semToUpdate.length; i += BACKFILL_BATCH_SIZE) { + const batch = semToUpdate.slice(i, i + BACKFILL_BATCH_SIZE); + const texts = batch.map((s) => s.fact); + try { + const vectors = await ep.embedBatch(texts); + for (let j = 0; j < batch.length; j++) { + batch[j].embedding = float32ToBase64(vectors[j]); + batch[j].embeddingModel = ep.name; + await kv.set(KV.semantic, batch[j].id, batch[j]); + } + results.semantic += batch.length; + } catch (e) { + logger.warn("Semantic backfill batch failed", { error: String(e) }); + } + } + + // 2. Procedural Skills + const procedurals = await kv.list(KV.procedural); + const procToUpdate = procedurals.filter( + (p) => !p.embedding || p.embeddingModel !== ep.name + ); + for (let i = 0; i < procToUpdate.length; i += BACKFILL_BATCH_SIZE) { + const batch = procToUpdate.slice(i, i + BACKFILL_BATCH_SIZE); + const texts = batch.map((p) => `${p.name} ${p.triggerCondition} ${p.steps.join(" ")}`); + try { + const vectors = await ep.embedBatch(texts); + for (let j = 0; j < batch.length; j++) { + batch[j].embedding = float32ToBase64(vectors[j]); + batch[j].embeddingModel = ep.name; + await kv.set(KV.procedural, batch[j].id, batch[j]); + } + results.procedural += batch.length; + } catch (e) { + logger.warn("Procedural backfill batch failed", { error: String(e) }); + } + } + + // 3. Crystals + const crystals = await kv.list(KV.crystals); + const crysToUpdate = crystals.filter( + (c) => !c.embedding || c.embeddingModel !== ep.name + ); + for (let i = 0; i < crysToUpdate.length; i += BACKFILL_BATCH_SIZE) { + const batch = crysToUpdate.slice(i, i + BACKFILL_BATCH_SIZE); + const texts = batch.map((c) => `${c.narrative} ${c.lessons.join(" ")}`); + try { + const vectors = await ep.embedBatch(texts); + for (let j = 0; j < batch.length; j++) { + batch[j].embedding = float32ToBase64(vectors[j]); + batch[j].embeddingModel = ep.name; + await kv.set(KV.crystals, batch[j].id, batch[j]); + } + results.crystals += batch.length; + } catch (e) { + logger.warn("Crystal backfill batch failed", { error: String(e) }); + } + } + + // 4. Insights + const insights = await kv.list(KV.insights); + const insToUpdate = insights.filter( + (ins) => !ins.deleted && (!ins.embedding || ins.embeddingModel !== ep.name) + ); + for (let i = 0; i < insToUpdate.length; i += BACKFILL_BATCH_SIZE) { + const batch = insToUpdate.slice(i, i + BACKFILL_BATCH_SIZE); + const texts = batch.map((ins) => `${ins.title} ${ins.content}`); + try { + const vectors = await ep.embedBatch(texts); + for (let j = 0; j < batch.length; j++) { + batch[j].embedding = float32ToBase64(vectors[j]); + batch[j].embeddingModel = ep.name; + await kv.set(KV.insights, batch[j].id, batch[j]); + } + results.insights += batch.length; + } catch (e) { + logger.warn("Insight backfill batch failed", { error: String(e) }); + } + } + + const total = results.semantic + results.procedural + results.crystals + results.insights; + if (total > 0) { + logger.info("High-order embedding backfill complete", { backfilled: results }); + } + + return { success: true, backfilled: results }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error("High-order backfill encountered a fatal error", { error: errorMsg }); + return { success: false, error: errorMsg }; + } + }); +} diff --git a/src/functions/high-order-search.ts b/src/functions/high-order-search.ts new file mode 100644 index 00000000..ba1b9af7 --- /dev/null +++ b/src/functions/high-order-search.ts @@ -0,0 +1,198 @@ +import type { StateKV } from "../state/kv.js"; +import type { + SemanticMemory, + ProceduralMemory, + Crystal, + Insight, + HighOrderTier, + CompactHighOrderResult, +} from "../types.js"; +import { KV } from "../state/schema.js"; + +const TIER_CAP = 50; +const CONTENT_PREVIEW_CHARS = 240; + +interface TierCandidate { + id: string; + tier: HighOrderTier; + text: string; + content: string; + confidence: number; + project?: string; + createdAt: string; +} + +import { getEmbeddingProvider } from "./search.js"; +import { base64ToFloat32 } from "../state/vector-index.js"; + +function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) return 0; + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +export async function searchHighOrderTiers( + kv: StateKV, + query: string, + options: { + confidenceFloor: number; + project?: string; + limit?: number; + }, +): Promise<{ results: CompactHighOrderResult[], needsBackfill: boolean }> { + const ep = getEmbeddingProvider(); + const [semantics, procedurals, crystals, insights] = await Promise.all([ + kv.list(KV.semantic).catch(() => []), + kv.list(KV.procedural).catch(() => []), + kv.list(KV.crystals).catch(() => []), + kv.list(KV.insights).catch(() => []), + ]); + + let needsBackfill = false; + let queryVector: Float32Array | null = null; + if (ep) { + try { + queryVector = await ep.embed(query); + } catch { + // Ignore embed error + } + } + + const candidates: Array = []; + + for (const s of semantics) { + const effectiveConfidence = Math.min(s.confidence, s.strength); + if (effectiveConfidence < options.confidenceFloor) continue; + if (ep && (!s.embedding || s.embeddingModel !== ep.name)) needsBackfill = true; + candidates.push({ + id: s.id, + tier: "semantic", + text: s.fact, + content: truncate(s.fact), + confidence: effectiveConfidence, + createdAt: s.createdAt, + embedding: s.embedding ? base64ToFloat32(s.embedding) : undefined, + }); + } + + for (const p of procedurals) { + if (p.strength < options.confidenceFloor) continue; + if (ep && (!p.embedding || p.embeddingModel !== ep.name)) needsBackfill = true; + const text = `${p.name} ${p.triggerCondition} ${p.steps.join(" ")} ${(p.tags || []).join(" ")}`; + candidates.push({ + id: p.id, + tier: "procedural", + text, + content: truncate(`${p.name}: ${p.triggerCondition}`), + confidence: p.strength, + createdAt: p.createdAt, + embedding: p.embedding ? base64ToFloat32(p.embedding) : undefined, + }); + } + + for (const c of crystals) { + if (options.project && c.project && c.project !== options.project) continue; + if (ep && (!c.embedding || c.embeddingModel !== ep.name)) needsBackfill = true; + const text = `${c.narrative} ${c.keyOutcomes.join(" ")} ${c.lessons.join(" ")}`; + candidates.push({ + id: c.id, + tier: "crystal", + text, + content: truncate(c.narrative), + confidence: 1.0, + project: c.project, + createdAt: c.createdAt, + embedding: c.embedding ? base64ToFloat32(c.embedding) : undefined, + }); + } + + for (const i of insights) { + if (i.deleted) continue; + if (i.confidence < options.confidenceFloor) continue; + if (options.project && i.project && i.project !== options.project) continue; + if (ep && (!i.embedding || i.embeddingModel !== ep.name)) needsBackfill = true; + const text = `${i.title} ${i.content} ${(i.tags || []).join(" ")}`; + candidates.push({ + id: i.id, + tier: "insight", + text, + content: truncate(`${i.title}: ${i.content}`), + confidence: i.confidence, + project: i.project, + createdAt: i.createdAt, + embedding: i.embedding ? base64ToFloat32(i.embedding) : undefined, + }); + } + + const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 1); + + // Scored candidates + const bm25Scored = candidates.map((c) => { + const lower = c.text.toLowerCase(); + const matchCount = terms.length === 0 ? 0 : terms.filter((t) => lower.includes(t)).length; + const relevance = terms.length === 0 ? 0 : matchCount / terms.length; + const confBoost = c.confidence > 0.85 ? 1.2 : 1.0; + return { candidate: c, score: relevance * c.confidence * confBoost }; + }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score); + + const vectorScored = candidates.map((c) => { + const score = (queryVector && c.embedding) ? cosineSimilarity(queryVector, c.embedding) : 0; + return { candidate: c, score: score * c.confidence }; + }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score); + + // RRF + const rrfMap = new Map(); + const K = 60; + + bm25Scored.forEach((item, index) => { + const rank = index + 1; + rrfMap.set(item.candidate.id, { candidate: item.candidate, rrfScore: 1 / (K + rank) }); + }); + + vectorScored.forEach((item, index) => { + const rank = index + 1; + const existing = rrfMap.get(item.candidate.id); + if (existing) { + existing.rrfScore += 1 / (K + rank); + } else { + rrfMap.set(item.candidate.id, { candidate: item.candidate, rrfScore: 1 / (K + rank) }); + } + }); + + const finalScored = Array.from(rrfMap.values()) + .sort((a, b) => b.rrfScore - a.rrfScore) + .map(x => { + const c = x.candidate; + return { + id: c.id, + tier: c.tier as HighOrderTier, + content: c.content, + score: Math.round(x.rrfScore * 10000) / 10000, + confidence: c.confidence, + project: c.project, + createdAt: c.createdAt, + }; + }); + + const tierCounts = new Map(); + const capped: CompactHighOrderResult[] = []; + for (const r of finalScored) { + const count = tierCounts.get(r.tier) || 0; + if (count >= TIER_CAP) continue; + tierCounts.set(r.tier, count + 1); + capped.push(r); + } + + return { results: capped.slice(0, options.limit ?? 20), needsBackfill }; +} + +function truncate(text: string): string { + if (text.length <= CONTENT_PREVIEW_CHARS) return text; + return text.slice(0, CONTENT_PREVIEW_CHARS) + "…"; +} diff --git a/src/functions/reflect.ts b/src/functions/reflect.ts index 3a2af829..c71c470a 100644 --- a/src/functions/reflect.ts +++ b/src/functions/reflect.ts @@ -12,6 +12,9 @@ import type { } from "../types.js"; import { recordAudit } from "./audit.js"; import { REFLECT_SYSTEM, buildReflectPrompt } from "../prompts/reflect.js"; +import { getEmbeddingProvider } from "./search.js"; +import { float32ToBase64 } from "../state/vector-index.js"; +import { logger } from "../logger.js"; interface ConceptCluster { concepts: string[]; @@ -278,9 +281,19 @@ export function registerReflectFunctions( const fp = fingerprintId("ins", content.trim().toLowerCase()); const existing = await kv.get(KV.insights, fp); + const ep = getEmbeddingProvider(); if (existing && !existing.deleted) { reinforceInsight(existing); + if (!existing.embedding && ep) { + try { + const text = `${existing.title} ${existing.content}`; + existing.embedding = float32ToBase64(await ep.embed(text)); + existing.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed existing insight", { id: existing.id, error: String(e) }); + } + } await kv.set(KV.insights, existing.id, existing); reinforced++; } else { @@ -301,6 +314,15 @@ export function registerReflectFunctions( updatedAt: now, decayRate: 0.05, }; + if (ep) { + try { + const text = `${insight.title} ${insight.content}`; + insight.embedding = float32ToBase64(await ep.embed(text)); + insight.embeddingModel = ep.name; + } catch (e) { + logger.warn("Failed to embed new insight", { error: String(e) }); + } + } await kv.set(KV.insights, insight.id, insight); newInsights++; } diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index fbdaad0d..d08889df 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -1,15 +1,21 @@ import type { ISdk } from "iii-sdk"; import type { + CompactHighOrderResult, CompactLessonResult, CompactSearchResult, CompressedObservation, + Crystal, HybridSearchResult, + Insight, Lesson, + ProceduralMemory, + SemanticMemory, } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAccessBatch } from "./access-tracker.js"; -import { getAgentId, isAgentScopeIsolated } from "../config.js"; +import { getAgentId, isAgentScopeIsolated, isHighOrderSearchEnabled, getHighOrderConfidenceFloor } from "../config.js"; +import { searchHighOrderTiers } from "./high-order-search.js"; import { logger } from "../logger.js"; // Compact mode trims each lesson's content for at-a-glance display. The @@ -28,9 +34,7 @@ export function registerSmartSearchFunction( limit?: number; project?: string; includeLessons?: boolean; - // optional per-call agent filter for runtimes routing many - // roles through one server. "*" opts out of the env-default - // scope and returns hits from every agent. + includeHighOrder?: boolean; agentId?: string; }) => { @@ -57,6 +61,27 @@ export function registerSmartSearchFunction( return null; }).filter((item): item is NonNullable => item !== null); + const highOrderItems: Array<{ tier: string; id: string; data: unknown }> = []; + const obsItems: typeof items = []; + + for (const item of items) { + if (item.obsId.startsWith("sem_")) { + const entry = await kv.get(KV.semantic, item.obsId).catch(() => null); + if (entry) highOrderItems.push({ tier: "semantic", id: entry.id, data: entry }); + } else if (item.obsId.startsWith("proc_") || item.obsId.startsWith("skill_")) { + const entry = await kv.get(KV.procedural, item.obsId).catch(() => null); + if (entry) highOrderItems.push({ tier: "procedural", id: entry.id, data: entry }); + } else if (item.obsId.startsWith("crys_")) { + const entry = await kv.get(KV.crystals, item.obsId).catch(() => null); + if (entry) highOrderItems.push({ tier: "crystal", id: entry.id, data: entry }); + } else if (item.obsId.startsWith("ins_")) { + const entry = await kv.get(KV.insights, item.obsId).catch(() => null); + if (entry && !entry.deleted) highOrderItems.push({ tier: "insight", id: entry.id, data: entry }); + } else { + obsItems.push(item); + } + } + const expanded: Array<{ obsId: string; sessionId: string; @@ -64,7 +89,7 @@ export function registerSmartSearchFunction( }> = []; const results = await Promise.all( - items.map(({ obsId, sessionId }) => + obsItems.map(({ obsId, sessionId }) => findObservation(kv, obsId, sessionId).then((obs) => obs ? { obsId, sessionId: obs.sessionId, observation: obs } : null, ), @@ -87,11 +112,12 @@ export function registerSmartSearchFunction( logger.info("Smart search expanded", { requested: data.expandIds.length, attempted: raw.length, - returned: scoped.length, + returned: scoped.length + highOrderItems.length, filteredOutOfScope: expanded.length - scoped.length, + highOrderExpanded: highOrderItems.length, truncated, }); - return { mode: "expanded", results: scoped, truncated }; + return { mode: "expanded", results: scoped, highOrder: highOrderItems, truncated }; } if (!data.query || typeof data.query !== "string" || !data.query.trim()) { @@ -99,28 +125,39 @@ export function registerSmartSearchFunction( } const limit = Math.max(1, Math.min(data.limit ?? 20, 100)); - // Lesson recall stays capped: lessons are denser than raw - // observations so 10 covers most recall flows. const lessonLimit = Math.min(limit, 10); const includeLessons = data.includeLessons !== false; - // Over-fetch when filtering. Hybrid search can't filter on - // agentId (BM25/vector indexes don't carry it), so we ask the - // searcher for more hits than we need and trim post-filter. 3× - // is a defensible middle ground: enough headroom for a small - // workload, capped at 300 so a 100-limit request never asks for - // thousands of hits. + const includeHighOrder = data.includeHighOrder !== false + && isHighOrderSearchEnabled() + && !filterAgentId; + const overFetchLimit = filterAgentId ? Math.min(limit * 3, 300) : limit; - const [hybridResults, lessons] = await Promise.all([ + const [hybridResults, lessons, highOrderResponse] = await Promise.all([ searchFn(data.query, overFetchLimit), includeLessons ? recallLessons(sdk, data.query, lessonLimit, data.project) : Promise.resolve([]), + includeHighOrder + ? searchHighOrderTiers(kv, data.query, { + confidenceFloor: getHighOrderConfidenceFloor(), + project: data.project, + limit: Math.min(limit, 20), + }) + : Promise.resolve({ results: [], needsBackfill: false }), ]); + const highOrderResults = Array.isArray(highOrderResponse) ? highOrderResponse : highOrderResponse.results; + const needsBackfill = Array.isArray(highOrderResponse) ? false : highOrderResponse.needsBackfill; + + if (needsBackfill) { + // Trigger background backfill fire-and-forget + sdk.trigger({ function_id: "mem::backfill-embeddings::high-order", payload: {} }).catch(() => {}); + } + const filteredHybrid = filterAgentId ? hybridResults .filter((r) => r.observation.agentId === filterAgentId) @@ -145,13 +182,18 @@ export function registerSmartSearchFunction( query: data.query, results: compact.length, lessons: lessons.length, + highOrder: highOrderResults.length, }); const response: { mode: "compact"; results: CompactSearchResult[]; lessons?: CompactLessonResult[]; + highOrder?: CompactHighOrderResult[]; } = { mode: "compact", results: compact }; if (includeLessons) response.lessons = lessons; + if (includeHighOrder && highOrderResults.length > 0) { + response.highOrder = highOrderResults; + } return response; }, ); diff --git a/src/index.ts b/src/index.ts index d20d4693..93914620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { isConsolidationEnabled, isContextInjectionEnabled, isDropStaleIndexEnabled, + isHighOrderSearchEnabled, } from "./config.js"; import { createProvider, @@ -249,6 +250,7 @@ async function main() { registerPatternsFunction(sdk, kv); registerRememberFunction(sdk, kv); registerEvictFunction(sdk, kv); + registerHighOrderBackfillFunction(sdk, kv); registerRelationsFunction(sdk, kv); registerTimelineFunction(sdk, kv); @@ -272,6 +274,7 @@ async function main() { registerConsolidationPipelineFunction(sdk, kv, provider); bootLog(`Consolidation pipeline: registered (CONSOLIDATION_ENABLED=${isConsolidationEnabled() ? "true" : "false"})`); + bootLog(`High-order search: ${isHighOrderSearchEnabled() ? "enabled" : "disabled"} (AGENTMEMORY_HIGH_ORDER_SEARCH)`); if (isAutoCompressEnabled()) { bootLog( @@ -516,7 +519,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 125 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 126 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e2144567..98287630 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -269,6 +269,7 @@ export function registerMcpEndpoints( query: args.query, expandIds, limit, + includeHighOrder: args.includeHighOrder !== "false", }, }); return { diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index c86d899b..fec18e2d 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -127,9 +127,13 @@ export const CORE_TOOLS: McpToolDef[] = [ query: { type: "string", description: "Search query" }, expandIds: { type: "string", - description: "Comma-separated observation IDs to expand", + description: "Comma-separated IDs to expand (observations, sem_*, proc_*, crys_*, ins_*)", }, limit: { type: "number", description: "Max results (default 10)" }, + includeHighOrder: { + type: "string", + description: "Set to 'false' to exclude high-order memory tiers (semantic, procedural, crystals, insights) from results. Default: enabled.", + }, }, required: ["query"], }, diff --git a/src/state/vector-index.ts b/src/state/vector-index.ts index d4b8bda7..d22184b6 100644 --- a/src/state/vector-index.ts +++ b/src/state/vector-index.ts @@ -5,13 +5,13 @@ // pool. Same risk on the encode side if the input Float32Array is itself // a sliced view. Reported as a phantom "2048 dimensions on disk" crash // in #455 / #469 / #584 / #587. -function float32ToBase64(arr: Float32Array): string { +export function float32ToBase64(arr: Float32Array): string { return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength).toString( "base64", ); } -function base64ToFloat32(b64: string): Float32Array { +export function base64ToFloat32(b64: string): Float32Array { const buf = Buffer.from(b64, "base64"); return new Float32Array( buf.buffer, diff --git a/src/triggers/api.ts b/src/triggers/api.ts index bbec4fcd..a9fd2467 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1107,7 +1107,19 @@ export function registerApiTriggers( body: { error: "query or expandIds is required" }, }; } - const result = await sdk.trigger({ function_id: "mem::smart-search", payload: req.body }); + const body = req.body as Record; + const result = await sdk.trigger({ + function_id: "mem::smart-search", + payload: { + ...(body.query !== undefined && { query: body.query }), + ...(body.expandIds !== undefined && { expandIds: body.expandIds }), + ...(body.limit !== undefined && { limit: body.limit }), + ...(body.project !== undefined && { project: body.project }), + ...(body.includeLessons !== undefined && { includeLessons: body.includeLessons }), + ...(body.includeHighOrder !== undefined && { includeHighOrder: body.includeHighOrder }), + ...(body.agentId !== undefined && { agentId: body.agentId }), + }, + }); return { status_code: 200, body: result }; }, ); @@ -1117,6 +1129,20 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/smart-search", http_method: "POST" }, }); + sdk.registerFunction("api::backfill-high-order", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::backfill-embeddings::high-order", payload: {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::backfill-high-order", + config: { api_path: "/agentmemory/backfill/high-order", http_method: "POST" }, + }); + sdk.registerFunction("api::timeline", async ( req: ApiRequest<{ diff --git a/src/types.ts b/src/types.ts index b734a4d2..c0fc370d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -268,6 +268,7 @@ export interface CompactSearchResult { type: ObservationType; score: number; timestamp: string; + tier?: "observation"; } export interface CompactLessonResult { @@ -280,6 +281,18 @@ export interface CompactLessonResult { tags: string[]; } +export type HighOrderTier = "semantic" | "procedural" | "crystal" | "insight"; + +export interface CompactHighOrderResult { + id: string; + tier: HighOrderTier; + content: string; + score: number; + confidence: number; + project?: string; + createdAt: string; +} + export interface TimelineEntry { observation: CompressedObservation; sessionId: string; @@ -457,6 +470,8 @@ export interface SemanticMemory { strength: number; createdAt: string; updatedAt: string; + embedding?: string; + embeddingModel?: string; } export interface ProceduralMemory { @@ -473,6 +488,8 @@ export interface ProceduralMemory { strength: number; createdAt: string; updatedAt: string; + embedding?: string; + embeddingModel?: string; } export interface TeamConfig { @@ -744,6 +761,8 @@ export interface Crystal { sessionId?: string; project?: string; createdAt: string; + embedding?: string; + embeddingModel?: string; } export interface Lesson { @@ -782,6 +801,8 @@ export interface Insight { lastDecayedAt?: string; decayRate: number; deleted?: boolean; + embedding?: string; + embeddingModel?: string; } export interface DiagnosticCheck { diff --git a/test/high-order-search.test.ts b/test/high-order-search.test.ts new file mode 100644 index 00000000..930ce709 --- /dev/null +++ b/test/high-order-search.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect } from "vitest"; +import { searchHighOrderTiers } from "../src/functions/high-order-search.js"; +import type { + SemanticMemory, + ProceduralMemory, + Crystal, + Insight, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeSemantic(overrides: Partial = {}): SemanticMemory { + return { + id: "sem_1", + fact: "TypeScript uses structural typing", + confidence: 0.9, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: new Date().toISOString(), + strength: 0.8, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeProcedural(overrides: Partial = {}): ProceduralMemory { + return { + id: "proc_1", + name: "Run vitest tests", + steps: ["npm run build", "npx vitest run"], + triggerCondition: "when testing code changes", + frequency: 3, + sourceSessionIds: [], + strength: 0.7, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeCrystal(overrides: Partial = {}): Crystal { + return { + id: "crys_1", + narrative: "Database migration completed successfully with zero downtime", + keyOutcomes: ["zero downtime", "all tables migrated"], + filesAffected: ["db/migrate.ts"], + lessons: ["always run migrations in a transaction"], + sourceActionIds: [], + project: "myproject", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeInsight(overrides: Partial = {}): Insight { + return { + id: "ins_1", + title: "TypeScript patterns improve code quality", + content: "Using strict TypeScript patterns consistently reduces bugs", + confidence: 0.85, + reinforcements: 2, + sourceConceptCluster: ["typescript", "patterns"], + sourceMemoryIds: [], + sourceLessonIds: [], + sourceCrystalIds: [], + tags: ["typescript", "quality"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + decayRate: 0.02, + ...overrides, + }; +} + +describe("searchHighOrderTiers", () => { + it("returns semantic facts matching query", async () => { + const kv = mockKV(); + await kv.set("mem:semantic", "sem_1", makeSemantic()); + + const { results } = await searchHighOrderTiers(kv as any, "TypeScript typing", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].tier).toBe("semantic"); + expect(results[0].id).toBe("sem_1"); + }); + + it("returns procedural skills matching query", async () => { + const kv = mockKV(); + await kv.set("mem:procedural", "proc_1", makeProcedural()); + + const { results } = await searchHighOrderTiers(kv as any, "vitest tests", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].tier).toBe("procedural"); + }); + + it("returns crystals matching query", async () => { + const kv = mockKV(); + await kv.set("mem:crystals", "crys_1", makeCrystal()); + + const { results } = await searchHighOrderTiers(kv as any, "database migration", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].tier).toBe("crystal"); + expect(results[0].confidence).toBe(1.0); + }); + + it("returns insights matching query", async () => { + const kv = mockKV(); + await kv.set("mem:insights", "ins_1", makeInsight()); + + const { results } = await searchHighOrderTiers(kv as any, "TypeScript patterns", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].tier).toBe("insight"); + }); + + it("filters by confidence floor", async () => { + const kv = mockKV(); + await kv.set( + "mem:semantic", + "sem_low", + makeSemantic({ id: "sem_low", confidence: 0.2, strength: 0.9, fact: "test fact" }), + ); + await kv.set( + "mem:semantic", + "sem_high", + makeSemantic({ id: "sem_high", confidence: 0.9, strength: 0.9, fact: "test fact" }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.5, + }); + + expect(results.length).toBe(1); + expect(results[0].id).toBe("sem_high"); + }); + + it("uses min(confidence, strength) for semantic decay interaction", async () => { + const kv = mockKV(); + // High confidence but decayed strength + await kv.set( + "mem:semantic", + "sem_1", + makeSemantic({ id: "sem_decayed", confidence: 0.9, strength: 0.1, fact: "test fact" }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.5, + }); + + expect(results.length).toBe(0); + }); + + it("filters procedural by strength", async () => { + const kv = mockKV(); + await kv.set( + "mem:procedural", + "proc_1", + makeProcedural({ id: "proc_weak", strength: 0.2, name: "test test" }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test test", { + confidenceFloor: 0.5, + }); + + expect(results.length).toBe(0); + }); + + it("skips deleted insights", async () => { + const kv = mockKV(); + await kv.set( + "mem:insights", + "ins_1", + makeInsight({ id: "ins_del", title: "test fact", deleted: true }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBe(0); + }); + + it("filters crystals by project", async () => { + const kv = mockKV(); + await kv.set("mem:crystals", "crys_a", makeCrystal({ id: "crys_a", project: "projA" })); + await kv.set("mem:crystals", "crys_b", makeCrystal({ id: "crys_b", project: "projB" })); + + const { results } = await searchHighOrderTiers(kv as any, "database migration", { + confidenceFloor: 0.3, + project: "projA", + }); + + expect(results.length).toBe(1); + expect(results[0].id).toBe("crys_a"); + }); + + it("filters insights by project", async () => { + const kv = mockKV(); + await kv.set("mem:insights", "ins_a", makeInsight({ id: "ins_a", project: "projA" })); + await kv.set("mem:insights", "ins_b", makeInsight({ id: "ins_b", project: "projB" })); + + const { results } = await searchHighOrderTiers(kv as any, "TypeScript patterns", { + confidenceFloor: 0.3, + project: "projA", + }); + + expect(results.length).toBe(1); + expect(results[0].id).toBe("ins_a"); + }); + + it("returns empty array for empty query terms", async () => { + const kv = mockKV(); + await kv.set("mem:semantic", "sem_1", makeSemantic()); + + const response = await searchHighOrderTiers(kv as any, "a", { + confidenceFloor: 0.3, + }); + + expect(response).toEqual({ results: [], needsBackfill: false }); + }); + + it("returns empty array when no tiers have data", async () => { + const kv = mockKV(); + + const response = await searchHighOrderTiers(kv as any, "test query", { + confidenceFloor: 0.3, + }); + + expect(response).toEqual({ results: [], needsBackfill: false }); + }); + + it("caps per-tier results at 50", async () => { + const kv = mockKV(); + for (let i = 0; i < 60; i++) { + await kv.set( + "mem:semantic", + `sem_${i}`, + makeSemantic({ fact: `test fact ${i}` }), + ); + } + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.3, + limit: 100, + }); + + const semanticCount = results.filter((r) => r.tier === "semantic").length; + expect(semanticCount).toBeLessThanOrEqual(50); + }); + + it("applies confidence boost for high-confidence entries", async () => { + const kv = mockKV(); + await kv.set( + "mem:semantic", + "sem_low", + makeSemantic({ id: "sem_low", fact: "test fact", confidence: 0.5, strength: 1.0 }), + ); + await kv.set( + "mem:semantic", + "sem_high", + makeSemantic({ id: "sem_high", fact: "test fact", confidence: 0.95, strength: 1.0 }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBe(2); + expect(results[0].id).toBe("sem_high"); + expect(results[0].score).toBeGreaterThan(results[1].score); + }); + + it("respects limit parameter", async () => { + const kv = mockKV(); + for (let i = 0; i < 5; i++) { + await kv.set( + "mem:semantic", + `sem_${i}`, + makeSemantic({ id: `sem_${i}`, fact: `test fact ${i}` }), + ); + } + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.3, + limit: 3, + }); + + expect(results.length).toBe(3); + }); + + it("searches across multiple tiers simultaneously", async () => { + const kv = mockKV(); + await kv.set("mem:semantic", "sem_1", makeSemantic({ fact: "shared keyword" })); + await kv.set("mem:procedural", "proc_1", makeProcedural({ name: "shared keyword" })); + + const { results } = await searchHighOrderTiers(kv as any, "shared keyword", { + confidenceFloor: 0.3, + }); + + const tiers = new Set(results.map((r) => r.tier)); + expect(tiers.size).toBeGreaterThanOrEqual(2); + expect(tiers.has("semantic")).toBe(true); + expect(tiers.has("procedural")).toBe(true); + }); + + it("truncates long content preview", async () => { + const kv = mockKV(); + const longFact = "a".repeat(300) + " test fact"; + await kv.set( + "mem:semantic", + "sem_1", + makeSemantic({ fact: longFact }), + ); + + const { results } = await searchHighOrderTiers(kv as any, "test fact", { + confidenceFloor: 0.3, + }); + + expect(results.length).toBe(1); + expect(results[0].content.length).toBeLessThanOrEqual(241); // 240 + ellipsis + expect(results[0].content.endsWith("…")).toBe(true); + }); +}); diff --git a/test/smart-search.test.ts b/test/smart-search.test.ts index 9d0c94e0..37354564 100644 --- a/test/smart-search.test.ts +++ b/test/smart-search.test.ts @@ -4,6 +4,17 @@ vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); +vi.mock("../src/config.js", async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + isHighOrderSearchEnabled: vi.fn().mockReturnValue(false), + getHighOrderConfidenceFloor: vi.fn().mockReturnValue(0.3), + isAgentScopeIsolated: vi.fn().mockReturnValue(false), + getAgentId: vi.fn().mockReturnValue(undefined), + }; +}); + import { registerSmartSearchFunction } from "../src/functions/smart-search.js"; import type { CompressedObservation, @@ -291,4 +302,167 @@ describe("Smart Search Function", () => { expect(result.lessons).toEqual([]); }); }); + + describe("high-order tier integration", () => { + beforeEach(async () => { + const { isHighOrderSearchEnabled, getHighOrderConfidenceFloor } = await import("../src/config.js"); + vi.mocked(isHighOrderSearchEnabled).mockReturnValue(true); + vi.mocked(getHighOrderConfidenceFloor).mockReturnValue(0.3); + + sdk.registerFunction("mem::lesson-recall", async () => ({ + success: true, + lessons: [], + })); + }); + + it("compact mode includes highOrder results when enabled", async () => { + await kv.set("mem:semantic", "sem_1", { + id: "sem_1", + fact: "auth handler uses JWT tokens for authentication", + confidence: 0.9, + strength: 0.8, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + })) as { results: any[]; highOrder?: any[] }; + + expect(result.highOrder).toBeDefined(); + expect(result.highOrder!.length).toBeGreaterThan(0); + expect(result.highOrder![0].tier).toBe("semantic"); + }); + + it("includeHighOrder=false excludes high-order results", async () => { + await kv.set("mem:semantic", "sem_1", { + id: "sem_1", + fact: "auth handler uses JWT tokens", + confidence: 0.9, + strength: 0.8, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + includeHighOrder: false, + })) as { results: any[]; highOrder?: any[] }; + + expect(result.highOrder).toBeUndefined(); + }); + + it("empty high-order tiers produce no highOrder field", async () => { + const result = (await sdk.trigger("mem::smart-search", { + query: "something unique with no match", + })) as { results: any[]; highOrder?: any[] }; + + expect(result.highOrder).toBeUndefined(); + }); + + it("expandIds dispatches sem_ prefix to semantic KV", async () => { + await kv.set("mem:semantic", "sem_test", { + id: "sem_test", + fact: "test semantic fact", + confidence: 0.9, + strength: 0.8, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "test", + expandIds: ["sem_test"], + })) as { mode: string; results: any[]; highOrder?: any[] }; + + expect(result.mode).toBe("expanded"); + expect(result.highOrder).toBeDefined(); + expect(result.highOrder!.length).toBe(1); + expect(result.highOrder![0].tier).toBe("semantic"); + expect(result.highOrder![0].id).toBe("sem_test"); + }); + + it("expandIds dispatches crys_ prefix to crystals KV", async () => { + await kv.set("mem:crystals", "crys_test", { + id: "crys_test", + narrative: "test crystal narrative", + keyOutcomes: ["outcome1"], + filesAffected: [], + lessons: ["lesson1"], + sourceActionIds: [], + project: "test-project", + createdAt: "2026-01-01T00:00:00Z", + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "test", + expandIds: ["crys_test"], + })) as { mode: string; results: any[]; highOrder?: any[] }; + + expect(result.highOrder!.length).toBe(1); + expect(result.highOrder![0].tier).toBe("crystal"); + }); + + it("expandIds skips deleted insights", async () => { + await kv.set("mem:insights", "ins_del", { + id: "ins_del", + title: "deleted insight", + content: "should be skipped", + confidence: 0.9, + reinforcements: 1, + sourceConceptCluster: [], + sourceMemoryIds: [], + sourceLessonIds: [], + sourceCrystalIds: [], + tags: [], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + decayRate: 0.02, + deleted: true, + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "test", + expandIds: ["ins_del"], + })) as { mode: string; results: any[]; highOrder?: any[] }; + + expect(result.highOrder!.length).toBe(0); + }); + + it("mixed expandIds returns both observations and high-order", async () => { + await kv.set("mem:semantic", "sem_mix", { + id: "sem_mix", + fact: "mixed test fact", + confidence: 0.9, + strength: 0.8, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "test", + expandIds: ["obs_1", "sem_mix"], + })) as { mode: string; results: any[]; highOrder?: any[] }; + + expect(result.mode).toBe("expanded"); + expect(result.results.length).toBe(1); + expect(result.highOrder!.length).toBe(1); + }); + }); });