Ctrl K
ComunidadGuías…

src/server/turn-context.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Turn-context seam — per-turn prompt contributions from optional add-ons.
//
// The core builds a turn's prompt and knows nothing about what an add-on might want to say about the
// user's current situation. A contributor returns a short block that is prepended to THIS turn's
// prompt only — never the cacheable contextBlock, never the persisted transcript.
//
// Sibling of the `features.ts` seam and shaped like it: typed, optional, registered before startup,
// a no-op when nothing is registered (the core registers NOTHING here). The core concatenates
// whatever comes back and interprets none of it.

/** What the core knows about the turn. `hints` is the opaque client-supplied per-send bag (the
 *  client's `setSendOptions` slot) — the core never interprets it; a contributor reads its own keys. */
export interface TurnContextInput {
  sessionId?: string;
  uid?: string;
  hints?: Record<string, unknown>;
}

/** Return a prompt block for this turn, or undefined/'' to contribute nothing. */
export type TurnContextContributor = (input: TurnContextInput) => string | undefined;

const contributors = new Map<string, TurnContextContributor>();

/** Register a per-turn prompt contributor. Called by an add-on before startup. Idempotent by name. */
export function registerTurnContext(name: string, contribute: TurnContextContributor): void {
  if (contributors.has(name)) return;
  contributors.set(name, contribute);
}

/** Clear all registered contributors. For tests: the registry is a process-global, so a test that
 *  asserts the empty-registry (no-contributor) behavior must reset it first or it inherits whatever
 *  another test file registered — bun's cross-file order is not stable across platforms. */
export function clearTurnContext(): void {
  contributors.clear();
}

/**
 * Collect every contributor's block for this turn, joined by a blank line. Empty when none is
 * registered — which is the core's own state, so the core's prompt is byte-identical to before.
 * A throwing contributor is contained (logged, others still contribute): a broken add-on must not
 * take down a turn.
 */
export function collectTurnContext(input: TurnContextInput): string {
  const blocks: string[] = [];
  for (const [name, contribute] of contributors) {
    try {
      const block = contribute(input)?.trim();
      if (block) blocks.push(block);
    } catch (err) {
      console.error(`[turn-context] ${name} failed:`, (err as Error)?.message ?? err);
    }
  }
  return blocks.join('\n\n');
}

/**
 * Sanitize a client-supplied hint value before a contributor interpolates it into the model prompt.
 * Strips control chars/newlines and caps length, so a crafted value can't forge a second block or
 * inject instructions. Generic (knows no add-on shape) and lives here because EVERY contributor
 * interpolates untrusted `hints` — keeping it on the seam makes the requirement discoverable.
 */
export function sanitizeHintField(value: string, max: number): string {
  return Array.from(value)
    .filter((c) => { const n = c.charCodeAt(0); return n >= 32 && n !== 127; })
    .join('')
    .slice(0, max)
    .trim();
}