/**
* Embedding I/O for semantic NPC activation (Phase B+).
*
* The side-effecting counterpart to the pure `selectCast` (select.ts): it turns
* each NPC's card text into a vector via an LM Studio embedding model, so an NPC
* can be brought into scene by meaning (the recent text is *about* them) and not
* only by their name appearing verbatim. The vectors are handed to `selectCast`,
* which stays pure (cosine similarity is just arithmetic).
*
* Mirrors `world/embed.ts` (kept separate so `characters` stays decoupled from
* `world`, per CLAUDE.md), with the same two cost controls plus one more:
* - **NPC vectors are cached by card text** — the cast is static across turns,
* so it is embedded once, not every turn.
* - The recent-text **query embedding can be supplied** by the caller (it is
* the same recent text the lore pass already embedded), so a turn that runs
* both lore and cast semantics embeds the query only once. Omit it and this
* embeds the query itself.
* - Graceful degradation: no embedding model → every vector is `null` and
* `selectCast` falls back to name-only matching.
*/
import type { LMStudioClient } from "@lmstudio/sdk";
import { resolveModel } from "../shared/embedding.js";
import { CharacterCard } from "./schema.js";
/** Card-text → embedding cache. The cast is static across turns. */
const castVectorCache = new Map<string, number[]>();
/** The text that represents an NPC for semantic matching (name + sheet body). */
function castText(npc: CharacterCard): string {
return [npc.name, npc.description, npc.personality, npc.scenario]
.map((s) => s.trim())
.filter((s) => s.length > 0)
.join("\n");
}
export interface CastEmbeddings {
/** Embedding of the recent text, or null if it couldn't be produced. */
queryEmbedding: number[] | null;
/** Per-NPC embeddings aligned by index to the cast passed in. */
castEmbeddings: (number[] | null)[];
/** Why the semantic path produced nothing, for the debug log; null on success. */
error: string | null;
}
/**
* Embed the NPCs' card text, plus the recent text (query) unless the caller
* already has it. NPC vectors are served from the content cache when possible;
* only cache misses and (optionally) the query hit the model. Any failure
* yields nulls (name-only fallback).
*
* @param queryEmbedding Pass a precomputed recent-text vector to skip embedding
* it again (the lore pass usually already produced it). Pass `undefined` to
* have this embed `recentText` itself.
*/
export async function embedCast(
client: LMStudioClient,
identifier: string,
cast: CharacterCard[],
recentText: string,
queryEmbedding?: number[] | null,
): Promise<CastEmbeddings> {
const nulls = cast.map(() => null as number[] | null);
const { model, error } = await resolveModel(client, identifier);
if (!model) {
return { queryEmbedding: queryEmbedding ?? null, castEmbeddings: nulls, error };
}
// NPC vectors: reuse cached ones, batch-embed the misses.
const castEmbeddings: (number[] | null)[] = cast.map(
(npc) => castVectorCache.get(castText(npc)) ?? null,
);
const missIndices = castEmbeddings
.map((vec, i) => (vec === null ? i : -1))
.filter((i) => i >= 0);
let embedError: string | null = null;
if (missIndices.length > 0) {
try {
const texts = missIndices.map((i) => castText(cast[i]));
const results = await model.embed(texts);
results.forEach((res, k) => {
const i = missIndices[k];
castVectorCache.set(castText(cast[i]), res.embedding);
castEmbeddings[i] = res.embedding;
});
} catch (e) {
// Leave the missed NPCs as null — they fall back to name matching.
embedError = `cast embedding failed: ${e instanceof Error ? e.message : String(e)}`;
}
}
// Query vector: reuse the caller's if given, else embed the recent text.
let query: number[] | null = queryEmbedding ?? null;
if (queryEmbedding === undefined) {
const text = recentText.trim();
if (text) {
try {
const res = await model.embed(text);
query = res.embedding;
} catch (e) {
query = null;
embedError = `query embedding failed: ${e instanceof Error ? e.message : String(e)}`;
}
}
}
return { queryEmbedding: query, castEmbeddings, error: embedError };
}