// --------------------------------------------------------------------------- // Memory Graph — Capability seeding for skills and CLI commands // // Creates graph nodes for skill/CLI capabilities so they participate in // semantic retrieval. // --------------------------------------------------------------------------- import { CLI_COMMAND_HELP } from "@vellumai/plugin-api"; import { and, eq, like, sql } from "drizzle-orm"; import { isAssistantFeatureFlagEnabled } from "../../../../config/assistant-feature-flags.js"; import { getConfig } from "../../../../config/loader.js"; import { isMemoryV1Active } from "../../../../config/memory-v3-gate.js"; import { resolveSkillStates } from "../../../../config/skill-state.js"; import { loadSkillCatalog, type SkillSummary, } from "../../../../config/skills.js"; import { enqueueMemoryJob, upsertEmbedGraphNodeJob, } from "../../../../persistence/jobs-store.js"; import { memoryGraphNodes } from "../../../../persistence/schema/index.js"; import { getCachedCatalogSync, getCatalog, } from "../../../../skills/catalog-cache.js"; import { getLogger } from "../logging.js"; import { memoryDbOrNull } from "../memory-db.js"; import type { SkillCapabilityInput } from "../substrate/skill-content.js"; import { createNode } from "./store.js"; import { CAPABILITY_CLI_SOURCE_PREFIX as CLI_SOURCE_PREFIX, CAPABILITY_SKILL_SOURCE_PREFIX as SKILL_SOURCE_PREFIX, } from "./types.js"; const log = getLogger("graph-capability-seed"); /** A remote-catalog entry as returned by the shared catalog cache. */ type RemoteCatalogSkill = Awaited>[number]; /** Project a locally-resolved skill summary to its capability fields. */ function fromSkillSummary(entry: SkillSummary): SkillCapabilityInput { return { id: entry.id, displayName: entry.displayName, description: entry.description, activationHints: entry.activationHints, avoidWhen: entry.avoidWhen, }; } /** * Project a remote-catalog entry to its capability fields. Display name and * hints live inside the entry's nested vellum metadata. */ function fromCatalogSkill(entry: RemoteCatalogSkill): SkillCapabilityInput { return { id: entry.id, displayName: entry.metadata?.vellum?.["display-name"] ?? entry.name, description: entry.description, activationHints: entry.metadata?.vellum?.["activation-hints"], avoidWhen: entry.metadata?.vellum?.["avoid-when"], }; } /** Default significance for capability nodes. */ const CAPABILITY_SIGNIFICANCE = 0.6; /** * Upsert a graph node for a skill capability. * Uses sourceConversations[0] as a stable key for deduplication. */ function upsertSkillCapabilityNode( skillId: string, input: SkillCapabilityInput, ): void { try { const content = buildSkillContent(input); const sourceKey = `${SKILL_SOURCE_PREFIX}${skillId}`; upsertCapabilityNode(sourceKey, content); } catch (err) { log.warn({ err, skillId }, "Failed to upsert skill capability graph node"); } } /** * Upsert a graph node for a CLI command capability. */ function upsertCliCapabilityNode( commandName: string, description: string, ): void { try { const content = `The "assistant ${commandName}" CLI command is available. ${description}.`; const sourceKey = `${CLI_SOURCE_PREFIX}${commandName}`; upsertCapabilityNode(sourceKey, content); } catch (err) { log.warn( { err, commandName }, "Failed to upsert CLI capability graph node", ); } } /** * Remove the graph node for a skill capability. */ export function deleteSkillCapabilityNode(skillId: string): void { try { const sourceKey = `${SKILL_SOURCE_PREFIX}${skillId}`; deleteCapabilityNode(sourceKey); } catch (err) { log.warn({ err, skillId }, "Failed to delete skill capability graph node"); } } /** * Seed graph nodes for all enabled skills. * Prunes stale nodes whose skills are no longer enabled. */ export function seedSkillGraphNodes(): void { try { const catalog = loadSkillCatalog(); const config = getConfig(); const resolved = resolveSkillStates(catalog, config); const enabled = resolved.filter((r) => r.state === "enabled"); const seenKeys = new Set(); for (const { summary } of enabled) { const input = fromSkillSummary(summary); if (summary.id === "mcp-setup") { const servers = config.mcp?.servers; if (servers) { const names = Object.keys(servers).filter( (name: string) => servers[name]?.enabled !== false, ); if (names.length > 0) { input.description += ` Configured: ${names.join(", ")}`; } } } upsertSkillCapabilityNode(summary.id, input); seenKeys.add(`${SKILL_SOURCE_PREFIX}${summary.id}`); } // Protect uninstalled catalog skills from pruning — they are seeded // asynchronously by seedUninstalledCatalogSkillMemories() and should // not be marked as "gone" just because they aren't locally installed. // When the catalog cache is cold (empty before the async fetch // completes), we can only prune locally managed skills; full // catalog-based pruning waits until the cache is populated. const cachedCatalog = getCachedCatalogSync(); if (cachedCatalog.length === 0) { // Catalog cache is cold — we can't enumerate remote catalog skills, so // skip catalog-based pruning to avoid incorrectly marking valid // uninstalled catalog nodes as gone. But still prune locally disabled // skills so stale capability nodes don't linger after cold start. log.info("Catalog cache is cold — pruning only locally disabled skills"); const disabled = resolved.filter((r) => r.state !== "enabled"); for (const { summary } of disabled) { deleteSkillCapabilityNode(summary.id); } } else { for (const entry of cachedCatalog) { const flagKey = entry.metadata?.vellum?.["feature-flag"]; if (flagKey && !isAssistantFeatureFlagEnabled(flagKey, config)) { continue; } seenKeys.add(`${SKILL_SOURCE_PREFIX}${entry.id}`); } pruneStaleCapabilities(SKILL_SOURCE_PREFIX, seenKeys); } // Clean up old-format capability nodes (skill:* and cli:*) that use the // legacy "{prefix}:{id}\n..." content format. Mark them as gone so they // stop appearing as duplicates. Idempotent — once cleaned, subsequent // runs find nothing. cleanupOldFormatCapabilityNodes(); } catch (err) { log.warn({ err }, "Failed to seed skill graph nodes"); } } /** * Seed graph nodes for all CLI commands. * Prunes stale nodes whose commands are no longer registered. */ export async function seedCliGraphNodes(): Promise { try { const seenKeys = new Set(); for (const help of CLI_COMMAND_HELP) { upsertCliCapabilityNode(help.name, help.description); seenKeys.add(`${CLI_SOURCE_PREFIX}${help.name}`); } pruneStaleCapabilities(CLI_SOURCE_PREFIX, seenKeys); } catch (err) { log.warn({ err }, "Failed to seed CLI graph nodes"); } } /** * Seed capability graph nodes for catalog skills that are not yet installed. * This makes uninstalled skills discoverable via memory injection so the LLM * can auto-install them via skill_load when relevant. * Best-effort: errors are logged but never thrown. */ export async function seedUninstalledCatalogSkillMemories(): Promise { try { const fullCatalog = await getCatalog(); if (fullCatalog.length === 0) { return; } const installedCatalog = loadSkillCatalog(); const installedIds = new Set(installedCatalog.map((s) => s.id)); const config = getConfig(); for (const entry of fullCatalog) { if (installedIds.has(entry.id)) { continue; } const flagKey = entry.metadata?.vellum?.["feature-flag"]; if (flagKey && !isAssistantFeatureFlagEnabled(flagKey, config)) { continue; } upsertSkillCapabilityNode(entry.id, fromCatalogSkill(entry)); } } catch (err) { log.warn({ err }, "Failed to seed uninstalled catalog skill memories"); } } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function buildSkillContent(input: SkillCapabilityInput): string { let content = `The "${input.displayName}" skill (${input.id}) is available. ${input.description}.`; if (input.activationHints && input.activationHints.length > 0) { content += ` Use when: ${input.activationHints.join("; ")}.`; } if (input.avoidWhen && input.avoidWhen.length > 0) { content += ` Avoid when: ${input.avoidWhen.join("; ")}.`; } if (content.length > 500) { content = content.slice(0, 500); } return content; } /** * Core upsert: find an existing capability node by its sourceKey, * create or update as needed. * * We store the sourceKey in sourceConversations[0] as a stable identifier * (capability nodes aren't tied to a real conversation). * * The node write itself is all-tier — the `list_memory` surface reads * capability nodes on every tier. The `embed_graph_node` enqueue is v1-only: * those rows target the v1 Qdrant collection, and dispatch discards them * while concept-page memory is active. */ function upsertCapabilityNode(sourceKey: string, content: string): void { const db = memoryDbOrNull("upsertCapabilityNode"); if (!db) { return; } // Find existing node by sourceKey stored in source_conversations JSON const existing = db .select() .from(memoryGraphNodes) .where( eq(memoryGraphNodes.sourceConversations, JSON.stringify([sourceKey])), ) .get(); const now = Date.now(); if (existing) { if (existing.content === content && existing.fidelity !== "gone") { // Same content — just touch lastAccessed (and backfill lastConsolidated // for nodes created before the fix so they don't decay immediately, // and backfill significance for nodes created before the raise to 0.6) const updates: Record = { lastAccessed: now }; if (existing.lastConsolidated === 0) { updates.lastConsolidated = now; } if (existing.significance < CAPABILITY_SIGNIFICANCE) { updates.significance = CAPABILITY_SIGNIFICANCE; } db.update(memoryGraphNodes) .set(updates) .where(eq(memoryGraphNodes.id, existing.id)) .run(); return; } // Content changed or was deleted — update db.update(memoryGraphNodes) .set({ content, fidelity: "vivid", lastAccessed: now, ...(existing.lastConsolidated === 0 ? { lastConsolidated: now } : {}), }) .where(eq(memoryGraphNodes.id, existing.id)) .run(); if (isMemoryV1Active(getConfig())) { upsertEmbedGraphNodeJob({ nodeId: existing.id }); } return; } // Create new capability node const node = createNode({ content, type: "procedural" as const, created: now, lastAccessed: now, lastConsolidated: now, eventDate: null, emotionalCharge: { valence: 0, intensity: 0, decayCurve: "permanent" as const, decayRate: 0, originalIntensity: 0, }, fidelity: "vivid" as const, confidence: 1.0, significance: CAPABILITY_SIGNIFICANCE, stability: 1000, // Effectively permanent — never decays reinforcementCount: 0, lastReinforced: now, sourceConversations: [sourceKey], sourceType: "direct" as const, narrativeRole: null, partOfStory: null, imageRefs: null, }); if (isMemoryV1Active(getConfig())) { upsertEmbedGraphNodeJob({ nodeId: node.id }); } log.info({ sourceKey, nodeId: node.id }, "Created capability graph node"); } /** * Soft-delete (mark as gone) a capability node by its sourceKey. */ function deleteCapabilityNode(sourceKey: string): void { const db = memoryDbOrNull("deleteCapabilityNode"); if (!db) { return; } const existing = db .select() .from(memoryGraphNodes) .where( eq(memoryGraphNodes.sourceConversations, JSON.stringify([sourceKey])), ) .get(); if (existing && existing.fidelity !== "gone") { db.update(memoryGraphNodes) .set({ fidelity: "gone", lastAccessed: Date.now() }) .where(eq(memoryGraphNodes.id, existing.id)) .run(); enqueueMemoryJob("delete_qdrant_vectors", { targetType: "graph_node", targetId: existing.id, }); } } /** * Find and soft-delete old-format capability memory nodes (skill:* and cli:*). * * The legacy system stored content as "skill:{id}\n{statement}" or * "cli:{command}\n{statement}". The current system uses prose format. * This marks any remaining old-format nodes as gone so they no longer * surface in retrieval. */ function cleanupOldFormatCapabilityNodes(): void { const db = memoryDbOrNull("cleanupOldFormatCapabilityNodes"); if (!db) { return; } const now = Date.now(); // --- skill:* old-format nodes --- const oldFormatNodes = db .select() .from(memoryGraphNodes) .where( and( eq(memoryGraphNodes.type, "procedural"), sql`${memoryGraphNodes.fidelity} != 'gone'`, sql`${memoryGraphNodes.content} LIKE 'skill:%'`, ), ) .all(); for (const node of oldFormatNodes) { // Verify this is truly old-format: "skill:{id}\n..." if (!/^skill:\S+\n/.test(node.content)) { continue; } db.update(memoryGraphNodes) .set({ fidelity: "gone", lastAccessed: now }) .where(eq(memoryGraphNodes.id, node.id)) .run(); enqueueMemoryJob("delete_qdrant_vectors", { targetType: "graph_node", targetId: node.id, }); log.info({ nodeId: node.id }, "Cleaned up old-format skill memory node"); } // --- cli:* old-format nodes --- const oldCliNodes = db .select() .from(memoryGraphNodes) .where( and( eq(memoryGraphNodes.type, "procedural"), sql`${memoryGraphNodes.fidelity} != 'gone'`, sql`${memoryGraphNodes.content} LIKE 'cli:%'`, ), ) .all(); for (const node of oldCliNodes) { if (!/^cli:\S+\n/.test(node.content)) { continue; } db.update(memoryGraphNodes) .set({ fidelity: "gone", lastAccessed: now }) .where(eq(memoryGraphNodes.id, node.id)) .run(); enqueueMemoryJob("delete_qdrant_vectors", { targetType: "graph_node", targetId: node.id, }); log.info({ nodeId: node.id }, "Cleaned up old-format CLI memory node"); } } /** * Remove capability nodes whose sourceKeys are no longer in the active set. */ function pruneStaleCapabilities(prefix: string, activeKeys: Set): void { const db = memoryDbOrNull("pruneStaleCapabilities"); if (!db) { return; } const allCapabilities = db .select() .from(memoryGraphNodes) .where(like(memoryGraphNodes.sourceConversations, `%${prefix}%`)) .all(); const now = Date.now(); for (const row of allCapabilities) { if (row.fidelity === "gone") { continue; } // Extract sourceKey from JSON try { const sources = JSON.parse(row.sourceConversations as string); const key = Array.isArray(sources) ? sources[0] : null; if (key && typeof key === "string" && !activeKeys.has(key)) { log.info( { sourceKey: key, nodeId: row.id }, "Pruning stale capability graph node", ); db.update(memoryGraphNodes) .set({ fidelity: "gone", lastAccessed: now }) .where(eq(memoryGraphNodes.id, row.id)) .run(); enqueueMemoryJob("delete_qdrant_vectors", { targetType: "graph_node", targetId: row.id, }); } } catch { // Skip malformed JSON } } }