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; }