Ctrl K
ComunidadGuías…

src/server/directives.ts

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

Archivo original ↗Preguntar a KiBot
export interface Directives {
  model?: string;
  turns?: number;
  thinking?: 'adaptive' | 'enabled' | 'disabled';
  effort?: 'low' | 'medium' | 'high' | 'max';
  engine?: string;
  /** claude-code SDK session resume for this conversation (overrides agent-config `sdkResume`). */
  resume?: boolean;
  /** agentx DELEGATE mode `[planner->worker]` (key `delegate`): the worker model; `model` (left side) plans. */
  delegate?: string;
  /** agentx ADVISOR mode `[worker<-advisor]` (key `advisor`): the advisor model; `model` (left side) works. */
  advisor?: string;
  /** `[off]` / `[mode:off]`: clears a sticky delegate/advisor mode. Consumed by streamChat, never stored. */
  modeOff?: boolean;
}

export interface ParsedPrompt {
  prompt: string;
  directives: Directives;
  /** An EXPLICIT model selection that no alias and no registered engine could resolve. Reported
   *  instead of applied: dropping it (the old behaviour) ran the turn on `config.model` — a
   *  DIFFERENT model than the caller asked for, with nothing but a `console.warn` to show for it.
   *  The caller turns this into a turn error; see `streamChat`. */
  unresolvedModel?: string;
}

// The default model lives in src/shared/models.ts so the client header names the same one.
export { DEFAULT_MODEL } from '../shared/models';

// Canonical model aliases + label (src/server/model-aliases.ts — derived from ai.libx.js's catalog).
// Re-exported here so the rest of shraga keeps importing model helpers from one place.
export { MODEL_ALIASES, modelShortLabel } from './model-aliases.ts';
import { MODEL_ALIASES } from './model-aliases.ts';

const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;

const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine', 'resume', 'delegate', 'advisor', 'mode'];

/** `a->b` (delegate) / `a<-b` (advisor) — agentx's two-model mode grammar; the left side may be empty. */
function splitModePair(token: string): { left: string; op: '->' | '<-'; right: string } | null {
  const i = token.search(/->|<-/);
  if (i === -1) return null;
  const right = token.slice(i + 2).trim();
  return right ? { left: token.slice(0, i).trim(), op: token.slice(i, i + 2) as '->' | '<-', right } : null;
}

/** MODEL_ALIASES only covers the bare Anthropic shorthands. A `provider/model` id
 *  (`cursor/composer-2.5`, `openai/gpt-5.6`) is already concrete — gating it on the alias table
 *  silently dropped it and ran the instance default instead. As a bare positional it is honoured
 *  only when a REGISTERED engine actually advertises it (see the resolver below) — an arbitrary
 *  `[src/foo.ts]` stays prompt text, which is what a second bracket group must not swallow. */
const isQualifiedModel = (v: string) => /^[a-z0-9._-]+\/[a-z0-9./_-]+$/.test(v);

/** Resolves a model token the alias table doesn't know against the REGISTERED engines' own model
 *  lists, and reports which engine owns it. Without this, `[composer-2.5]` (an add-on engine's model) was
 *  warned about and dropped, so the turn silently ran on the previous engine/model. Injected by
 *  `initEngines()` — directives.ts stays pure and dependency-free for CE and for tests. */
export type ModelResolver = (token: string) => { model: string; engine?: string } | null;
let modelResolver: ModelResolver | null = null;
export function setModelResolver(fn: ModelResolver | null): void { modelResolver = fn; }

/** Alias table first (canonical shorthands win), then the engine registry. Exported because the
 *  client's directive editor needs the SAME verdict on "is this bare token a model?" as the parser —
 *  a second opinion there would strip a token the server still reads, or keep one it doesn't. */
export function resolveModelAlias(token: string): { model: string; engine?: string } | null {
  const v = token.trim().toLowerCase();
  if (MODEL_ALIASES[v]) return { model: MODEL_ALIASES[v] };
  return modelResolver?.(v) ?? null;
}

function resolveModelToken(d: Directives, val: string): boolean {
  const hit = resolveModelAlias(val);
  if (!hit) return false;
  d.model = hit.model;
  // An engine-owned model implies its engine — but never override an explicit `[engine:x]`.
  if (hit.engine && !d.engine) d.engine = hit.engine;
  return true;
}

/** Does a bracket group look like directives (vs. prompt text that happens to start with `[`)?
 * Every token must be a known key:value or a known positional, else we leave the group alone. */
export function isDirectiveGroup(raw: string): boolean {
  const tokens = raw.split(',').map((t) => t.trim()).filter(Boolean);
  if (!tokens.length) return false;
  return tokens.every((t) => {
    const colonIdx = t.indexOf(':');
    if (colonIdx !== -1) return DIRECTIVE_KEYS.includes(t.slice(0, colonIdx).trim().toLowerCase());
    const v = t.toLowerCase();
    const pair = splitModePair(v);
    if (pair) return (!pair.left || !!resolveModelAlias(pair.left)) && !!resolveModelAlias(pair.right);
    if (v === 'off') return true;
    return !!MODEL_ALIASES[v] || !!modelResolver?.(v) || /^\d+$/.test(v) || ['think', 'adaptive', 'nothink', 'nothinking'].includes(v);
  });
}

/** Splits the leading `[..]` directive groups off a prompt — the single answer to "where do the
 *  directives end". Consumes EVERY consecutive leading group, not just the first: a prompt may
 *  legitimately open with `[engine:x,model:y] [turns:120] …`, and a single-group scan silently
 *  dropped the second (a schedule pinned to opus quietly ran on the config default for three days).
 *
 *  `strict` requires every group — the first included — to actually look like directives. The parser
 *  stays lenient there (long-standing contract: `[unknown] hi` strips the group and warns), but an
 *  EDITOR rewriting the directive in place must never swallow a prompt whose body legitimately opens
 *  with bracketed prose. */
export function splitDirectiveGroups(text: string, opts?: { strict?: boolean }): { groups: string[]; body: string } {
  let rest = text;
  const groups: string[] = [];
  for (;;) {
    const m = rest.match(DIRECTIVE_RE);
    if (!m) break;
    const g = m[1].trim();
    if ((groups.length || opts?.strict) && g && !isDirectiveGroup(g)) break;
    groups.push(g);
    rest = m[2];
  }
  return { groups, body: rest };
}

export function parseDirectives(text: string): ParsedPrompt {
  const { groups, body: rest } = splitDirectiveGroups(text);
  if (!groups.length) return { prompt: text, directives: {} };

  const raw = groups.filter(Boolean).join(',');
  const prompt = rest.trim();
  if (!raw) return { prompt, directives: {} };

  const directives: Directives = {};
  let positionalIndex = 0;
  let unresolvedModel: string | undefined;

  const tokens = raw.split(',');
  // Is this group PROVABLY a directive group (some token is a known `key:value`)? A bare positional
  // that resolves to nothing is otherwise indistinguishable from prose — `[some bracketed text] hi`
  // is a legitimate prompt and must never fail a turn. With a keyed sibling present the group is
  // unambiguously directives, so an unresolvable leading model token there IS a dropped selection.
  const provenDirectiveGroup = tokens.some((t) => {
    const i = t.indexOf(':');
    return i !== -1 && DIRECTIVE_KEYS.includes(t.slice(0, i).trim().toLowerCase());
  });

  for (const token of tokens) {
    const t = token.trim();
    if (!t) continue;

    const colonIdx = t.indexOf(':');
    if (colonIdx !== -1) {
      const key = t.slice(0, colonIdx).trim().toLowerCase();
      const val = t.slice(colonIdx + 1).trim().toLowerCase();
      unresolvedModel = applyDirective(directives, key, val) ?? unresolvedModel;
    } else {
      const val = t.toLowerCase();
      const pair = splitModePair(val);
      if (pair) {
        // Both sides must resolve: a half-resolved pair would silently run a mode on the wrong model.
        const worker = resolveModelAlias(pair.right);
        if (worker && (!pair.left || resolveModelToken(directives, pair.left))) {
          if (pair.op === '->') directives.delegate = worker.model; else directives.advisor = worker.model;
        } else unresolvedModel ??= t;
        positionalIndex++;
        continue;
      }
      if (val === 'off') { directives.modeOff = true; positionalIndex++; continue; }
      if (positionalIndex === 0 && resolveModelToken(directives, val)) {
        // handled
      } else if (positionalIndex <= 1 && /^\d+$/.test(val)) {
        directives.turns = parseInt(val, 10);
      } else if (['think', 'adaptive'].includes(val)) {
        directives.thinking = 'adaptive';
      } else if (['nothink', 'nothinking'].includes(val)) {
        directives.thinking = 'disabled';
      } else if (positionalIndex === 0) {
        if (provenDirectiveGroup && !isQualifiedModel(val)) unresolvedModel ??= t;
        console.warn(isQualifiedModel(val)
          ? `[directives] Provider-qualified model needs key form: "[model:${t}]"`
          : `[directives] Unknown model alias: "${t}"`);
      }
      positionalIndex++;
    }
  }

  return { prompt, directives, unresolvedModel };
}

/** Returns the token of an EXPLICIT model selection it could not resolve, else undefined. */
function applyDirective(d: Directives, key: string, val: string): string | undefined {
  switch (key) {
    case 'model':
      if (resolveModelToken(d, val)) break;
      // A `provider/model` id is already concrete — hand it to the engine, which owns the verdict.
      if (isQualifiedModel(val)) { d.model = val; break; }
      // Nothing knows this token. `[model:x]` is unambiguously a selection (never prose), so the
      // turn must not quietly continue on the instance default model.
      return val;
    case 'turns':
      if (/^\d+$/.test(val)) d.turns = parseInt(val, 10);
      else console.warn(`[directives] Invalid turns value: "${val}"`);
      break;
    case 'thinking':
    case 'think':
      if (['adaptive', 'enabled', 'disabled'].includes(val)) d.thinking = val as Directives['thinking'];
      else console.warn(`[directives] Invalid thinking value: "${val}"`);
      break;
    case 'effort':
      if (['low', 'medium', 'high', 'max'].includes(val)) d.effort = val as Directives['effort'];
      else console.warn(`[directives] Invalid effort value: "${val}"`);
      break;
    case 'engine':
      d.engine = val;
      break;
    case 'delegate':
    case 'advisor': {
      const hit = resolveModelAlias(val);
      if (hit) d[key] = hit.model;
      else if (isQualifiedModel(val)) d[key] = val;
      else return val;
      break;
    }
    case 'mode':
      if (val === 'off') d.modeOff = true;
      else console.warn(`[directives] Invalid mode value: "${val}"`);
      break;
    case 'resume':
      if (['on', 'true', '1'].includes(val)) d.resume = true;
      else if (['off', 'false', '0'].includes(val)) d.resume = false;
      else console.warn(`[directives] Invalid resume value: "${val}"`);
      break;
    default:
      console.warn(`[directives] Unknown directive key: "${key}"`);
  }
  return undefined;
}