// --------------------------------------------------------------------------- // Memory substrate — `assistant` CLI subcommands → embedded capability entries // --------------------------------------------------------------------------- // // Shared by the v2 injection engine and memory-v3; active whenever // `usesConceptPageMemory()` holds. // // Enumerate the top-level `assistant` CLI commands from their declarative help // (`CLI_COMMAND_HELP`, exposed via `@vellumai/plugin-api`), render each as a // prose capability statement, embed dense + sparse, and upsert into // `memory_v2_concept_pages` under the slug `cli-commands/`. The router // scores these alongside concept pages and skill entries; the injection layer // surfaces hits under `### CLI Commands You Can Use` so the model can // semantically discover a CLI capability it would not otherwise know to reach // for. // // The declarative help is pure data, so the seed reads it without importing the // CLI's action graph (`cli/program.ts`) — the graph pulls in provider and // workspace modules that were a recurring source of test-mock cascades and // circular imports. // // Mirrors `skill-store.ts` deliberately: same single-flight + generation // coalescing, same dense + sparse + corpus-stats-aware sparse encoding, same // payload-kind discriminator, same atomic cache replacement. Differences: // - No remote catalog — the source of truth is the declarative CLI help. // - No per-entry feature-flag filter. // - No MCP-style augmentation — the declared description is the canonical // summary. import { CLI_COMMAND_HELP } from "@vellumai/plugin-api"; import { getConfig } from "../../../../config/loader.js"; import { generateSparseEmbedding } from "../../../../persistence/embeddings/embedding-backend.js"; import { applyCorrectionIfCalibrated } from "../anisotropy.js"; import { embedWithBackend } from "../embeddings.js"; import { getLogger } from "../logging.js"; import { buildCliCommandHelpContent } from "./cli-command-content.js"; import { invalidatePageIndex } from "./page-index.js"; import { backfillKindOnPointsWithPrefix, pruneSlugsWithPrefixExcept, upsertConceptPageEmbedding, } from "./qdrant.js"; import { generateBm25DocEmbedding, getConceptPageCorpusStats, } from "./sparse-bm25.js"; import { resolveSubstrateTuning } from "./tuning.js"; import type { CliCommandEntry } from "./types.js"; const log = getLogger("memory-v2-cli-command-store"); /** * Slug prefix under which CLI-command embeddings are indexed in * `memory_v2_concept_pages`. Concept-page slugs must match * `[a-z0-9][a-z0-9-]*(/...)*`, and `cli-commands` matches that pattern, so the * prefix coexists with hand-authored concept pages without escape work. */ export const CLI_COMMAND_SLUG_PREFIX = "cli-commands/"; /** * Payload discriminator written on every CLI-command-seeded Qdrant point. * Keeps CLI rows distinguishable from user-authored concept pages and from * skill rows that happen to live in adjacent namespaces, so prefix pruning * never deletes a hand-authored page sitting under `cli-commands/...`. */ const CLI_COMMAND_PAYLOAD_KIND = "cli-command"; /** Compose the unified-collection slug for a CLI command name. */ export function cliCommandSlugFor(name: string): string { return `${CLI_COMMAND_SLUG_PREFIX}${name}`; } /** * Module-level cache of rendered CLI-command entries keyed by command name. * `null` until the first successful seed run completes; replaced atomically * on each successful re-seed so callers always see a consistent snapshot. */ let entries: Map | null = null; let requestedSeedGeneration = 0; let processedSeedGeneration = 0; let activeSeedDrain: Promise | null = null; let lastSeedError: unknown = null; const seedWaiters: Array<{ generation: number; resolve: () => void }> = []; /** * In-process latch for the legacy `kind` backfill. New upserts always write * `kind`, so once the latch is set there is no follow-up work to do this * process. */ let legacyKindBackfillDone = false; /** * Seed (or re-seed) CLI-command embeddings into the unified concept-page * collection. Idempotent. Best-effort for background callers (errors are * logged but swallowed); pass `{ throwOnError: true }` from synchronous CLI * paths that want failures surfaced. * * Single-flight + coalesced: at most one seed runs at a time. Requests made * while a seed is in flight advance the requested generation; stale in-flight * snapshots are skipped before they write embeddings or replace the cache. */ export async function seedV2CliCommandEntries( opts: { throwOnError?: boolean } = {}, ): Promise { const generation = ++requestedSeedGeneration; const waiter = new Promise((resolve) => { seedWaiters.push({ generation, resolve }); }); startSeedDrainIfNeeded(); await waiter; if (opts.throwOnError && lastSeedError) { throw lastSeedError; } } function startSeedDrainIfNeeded(): void { if (activeSeedDrain) { return; } if (processedSeedGeneration >= requestedSeedGeneration) { return; } activeSeedDrain = drainSeedQueue().finally(() => { activeSeedDrain = null; startSeedDrainIfNeeded(); }); } async function drainSeedQueue(): Promise { while (processedSeedGeneration < requestedSeedGeneration) { const generationToProcess = requestedSeedGeneration; await runSeedV2CliCommandEntries(generationToProcess); processedSeedGeneration = generationToProcess; resolveSeedWaiters(); } } function resolveSeedWaiters(): void { for (let i = seedWaiters.length - 1; i >= 0; i -= 1) { const waiter = seedWaiters[i]!; if (waiter.generation > processedSeedGeneration) { continue; } seedWaiters.splice(i, 1); waiter.resolve(); } } async function runSeedV2CliCommandEntries(generation: number): Promise { try { const config = getConfig(); const seeds: CliCommandEntry[] = []; // Every top-level `assistant` CLI command declares its help as pure data // (`CLI_COMMAND_HELP`, exposed via `@vellumai/plugin-api`); render each into // a capability statement here in the plugin. Reading declarative help keeps // the CLI's action graph — and the daemon/provider modules it imports — out // of this plugin's import tree, so the seed never touches `cli/program.ts`. for (const help of CLI_COMMAND_HELP) { seeds.push({ id: help.name, description: help.description, content: buildCliCommandHelpContent(help), }); } // Sparse (BM25/TF) encoding is computed locally; only the dense vectors // require `embedWithBackend`, which is unconfigured during the cold-start // window before a managed-proxy embedding credential is provisioned. A // dense-embed failure is non-fatal to the in-memory cache: the v3 needle // finder lane reads CLI capabilities from `entries` / the page index, NOT // from Qdrant, so the cache is populated from the local Commander tree // regardless of backend state and commands stay discoverable from first // boot. Only the dense Qdrant upsert is skipped; the managed-credential // reseed and the v3 maintain pass backfill the dense vectors on recovery. const nextEntries = new Map(); let denseVectors: number[][] = []; let denseAvailable = false; let denseError: unknown = null; let encodeSparse: ( input: string, ) => ReturnType = generateSparseEmbedding; if (seeds.length > 0) { // CLI commands share the concept-page Qdrant collection, so the sparse // vector must use the same stemmed BM25 encoding as the concept-page // documents. Fall back to the legacy TF encoder only during the cold- // start window before corpus stats finish building — same rationale as // the skill-store path. const corpusStats = getConceptPageCorpusStats(); const { bm25_k1, bm25_b } = resolveSubstrateTuning(config.memory); encodeSparse = (input: string) => corpusStats ? generateBm25DocEmbedding(input, corpusStats, { k1: bm25_k1, b: bm25_b, }) : generateSparseEmbedding(input); try { const embedded = await embedWithBackend( config, seeds.map((s) => s.content), ); denseVectors = await Promise.all( embedded.vectors.map((v) => applyCorrectionIfCalibrated(v, embedded.provider, embedded.model), ), ); denseAvailable = true; } catch (err) { denseError = err; log.warn( { err }, "Embedding backend unavailable — seeding CLI-command cache without dense Qdrant vectors; the needle lane surfaces commands from the cache and the dense lane backfills when the backend recovers", ); } } if (generation !== requestedSeedGeneration) { log.info( { generation, latestGeneration: requestedSeedGeneration }, "Skipping stale v2 CLI-command seed result", ); lastSeedError = null; return; } // Populate the in-memory cache (and therefore the page index / needle lane) // from the local Commander tree regardless of dense availability. for (const seed of seeds) { nextEntries.set(seed.id, seed); } // Write the dense+sparse Qdrant points only when dense vectors were // produced. In the degraded (backend-unavailable) path we skip the upsert // so we never write half-formed points; the dense lane backfills on // recovery. if (seeds.length > 0 && denseAvailable) { const now = Date.now(); await Promise.all( seeds.map((seed, i) => upsertConceptPageEmbedding({ slug: cliCommandSlugFor(seed.id), dense: denseVectors[i], sparse: encodeSparse(seed.content), updatedAt: now, kind: CLI_COMMAND_PAYLOAD_KIND, }), ), ); } // The CLI tree is always available (no remote catalog), so pruning to clear // stale rows runs whenever we wrote the current set this run OR there was // nothing to embed (empty tree). The cold-start degraded path (commands // present but dense embedding unavailable) skips the prune so we never // reconcile the collection against a set we did not persist. Run the legacy // `kind` backfill once per process so pre-discriminator rows become prunable. if (denseAvailable || seeds.length === 0) { const knownIds = new Set(seeds.map((s) => s.id)); if (!legacyKindBackfillDone) { try { await backfillKindOnPointsWithPrefix( CLI_COMMAND_SLUG_PREFIX, CLI_COMMAND_PAYLOAD_KIND, knownIds, ); legacyKindBackfillDone = true; } catch (err) { log.warn( { err }, "Failed to backfill kind on legacy CLI-command points — pruning may leave orphans this run", ); } } await pruneSlugsWithPrefixExcept( CLI_COMMAND_SLUG_PREFIX, seeds.map((s) => s.id), { kind: CLI_COMMAND_PAYLOAD_KIND }, ); } // Atomically replace the cache from the freshly enumerated commands. Drop // the page-index cache so the next router invocation observes the new // command set. entries = nextEntries; invalidatePageIndex(); // Surface a dense-embed failure to `throwOnError` callers so the existing // retry + maintain machinery backfills the dense lane. The in-memory cache // is already updated above, so the needle lane is fixed regardless. lastSeedError = denseError; } catch (err) { lastSeedError = err; log.warn({ err }, "Failed to seed v2 CLI-command entries"); } } /** * Synchronous lookup of a previously-seeded `CliCommandEntry` by command * name. Returns `null` when the cache has not yet been populated, when the * id is unknown, or when a prior seed run dropped the id. * * Accepts either a bare command name (`attachment`) or its unified-collection * slug (`cli-commands/attachment`) so render-side callers can pass through * what they have without a manual prefix strip. * * Returns a frozen copy so callers cannot mutate the underlying cache entry. */ export function getCliCommandCapability( idOrSlug: string, ): CliCommandEntry | null { const id = idOrSlug.startsWith(CLI_COMMAND_SLUG_PREFIX) ? idOrSlug.slice(CLI_COMMAND_SLUG_PREFIX.length) : idOrSlug; const entry = entries?.get(id); return entry ? Object.freeze({ ...entry }) : null; } /** True iff the slug refers to a CLI-command entry in the unified collection. */ export function isCliCommandSlug(slug: string): boolean { return slug.startsWith(CLI_COMMAND_SLUG_PREFIX); } /** * Snapshot of the in-process CLI-command cache, sorted by command name (ASCII * order) for determinism. Returns a freshly allocated array of frozen entry * copies on each call. */ export function listCliCommandEntries(): CliCommandEntry[] { if (!entries) { return []; } return [...entries.values()] .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .map((entry) => Object.freeze({ ...entry })); } /** @internal Test-only: clear the module-level cache. */ export function _resetCliCommandStoreForTests(): void { entries = null; requestedSeedGeneration = 0; processedSeedGeneration = 0; activeSeedDrain = null; seedWaiters.splice(0, seedWaiters.length); lastSeedError = null; legacyKindBackfillDone = false; }