Files
waggle-os/packages/server/src/local/command-interpret.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

177 lines
7.1 KiB
TypeScript

/**
* NL Command Bar — Tier 1 intent resolver (the LLM front-end).
*
* Builds the resolver prompt (action catalog + memory context), calls a fast
* model, parses its JSON defensively, and maps the result onto the CLOSED
* registry (`command-registry.ts`). The LLM is injected so the logic is unit-
* testable without a live model; the route supplies the proxy call.
*
* Resolution is routing, not a full agent turn — keep it cheap. On any failure
* (no key, model error, unparseable output, unknown action) we degrade to
* `{ kind:'none', fallback:true }` so the frontend falls back to Tier 0.
*/
import type { InterpretResult, ResolvedAction, Tier } from '@waggle/shared';
import {
buildActionCatalog,
validateAndBuildAction,
checkTier,
type RegistryContext,
} from './command-registry.js';
export interface InterpretDeps {
text: string;
workspaceId?: string;
currentTier: Tier;
workspaces: ReadonlyArray<{ id: string; name: string }>;
/** The workspace "now" block (awareness + recent sessions + pending). */
memoryContext: string;
/** Returns raw model content, or null on no-key / error. */
llm: (systemPrompt: string, userText: string) => Promise<string | null>;
log?: (msg: string) => void;
}
const FALLBACK: InterpretResult = {
kind: 'none',
fallback: true,
message: "Couldn't interpret that — showing the closest matches instead.",
};
/** Strip ``` fences and isolate the first JSON object. */
function parseJsonObject(raw: string): Record<string, unknown> | null {
if (!raw) return null;
const fenceless = raw.replace(/```(?:json)?/gi, '').trim();
const match = fenceless.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
const parsed = JSON.parse(match[0]);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function buildSystemPrompt(deps: InterpretDeps): string {
const catalog = buildActionCatalog();
const recentWorkspaces = deps.workspaces.length
? deps.workspaces.slice(0, 15).map((w) => `- ${w.id} :: ${w.name}`).join('\n')
: '(none)';
const memory = deps.memoryContext.trim() || '(no active workspace state)';
return `You are the Waggle command resolver. Map the user's natural-language request onto EXACTLY ONE action from the ACTION REGISTRY, or ask to clarify, or decline. You translate intent into a known action — you do NOT execute anything, write code, or invent actions, endpoints, ids, or parameters outside what is listed.
${catalog}
RECENT WORKSPACES (id :: name) — for open_workspace:
${recentWorkspaces}
CURRENT WORKSPACE STATE (memory — use it to resolve "continue", "yesterday's thing", "the X workspace"):
${memory}
Output STRICT JSON only — no prose, no markdown fences. One of:
{"kind":"action","actionId":"<registry id>","params":{...}}
{"kind":"plan","steps":[{"actionId":"<id>","params":{...}}, ...]}
{"kind":"clarify","question":"<one short question>","options":["opt1","opt2"]}
{"kind":"none","message":"<one short sentence>"}
Rules:
- Prefer a single action. Use open_app for "show/open/go to <screen>". Use open_workspace to resume/continue or open an existing workspace (for "continue what I was working on", pick the most recent RECENT WORKSPACE). Use create_workspace only for a clearly NEW workspace. Use install_mcp only to install/add an MCP server.
- If a required param is missing and cannot be inferred, return clarify.
- If the request genuinely needs several distinct registry actions, return plan.
- If the request cannot be expressed with the registry, return none with a brief reason. Never invent an action id or free-form behavior.`;
}
function buildValidStep(
step: unknown,
ctx: RegistryContext,
): ResolvedAction | null {
if (!step || typeof step !== 'object') return null;
const s = step as Record<string, unknown>;
const actionId = typeof s.actionId === 'string' ? s.actionId : undefined;
if (!actionId) return null;
const params = (s.params && typeof s.params === 'object' ? s.params : {}) as Record<string, unknown>;
return validateAndBuildAction(actionId, params, ctx);
}
/** Apply the tier gate to a resolved single action. */
function finalizeAction(action: ResolvedAction, currentTier: Tier): InterpretResult {
const tier = checkTier(action.id, currentTier);
if (tier.gated && tier.requiredTier) {
return {
kind: 'tier_gated',
capability: action.label,
requiredTier: tier.requiredTier,
actualTier: currentTier,
message: `${action.label} requires the ${tier.requiredTier} tier.`,
};
}
return { kind: 'action', action };
}
export async function interpretCommand(deps: InterpretDeps): Promise<InterpretResult> {
const ctx: RegistryContext = { workspaceId: deps.workspaceId, workspaces: deps.workspaces };
let content: string | null;
try {
content = await deps.llm(buildSystemPrompt(deps), deps.text);
} catch (err) {
deps.log?.(`command/interpret: llm error — ${err instanceof Error ? err.message : String(err)}`);
return FALLBACK;
}
if (!content) return FALLBACK;
const parsed = parseJsonObject(content);
if (!parsed) {
deps.log?.('command/interpret: model output was not valid JSON');
return FALLBACK;
}
const kind = typeof parsed.kind === 'string' ? parsed.kind : '';
switch (kind) {
case 'action': {
const actionId = typeof parsed.actionId === 'string' ? parsed.actionId : '';
const params = (parsed.params && typeof parsed.params === 'object' ? parsed.params : {}) as Record<string, unknown>;
const action = validateAndBuildAction(actionId, params, ctx);
if (!action) {
return { kind: 'none', fallback: true, message: "I couldn't map that to an action I can run." };
}
return finalizeAction(action, deps.currentTier);
}
case 'plan': {
// v1: typed but NOT executed. Collapse a 1-step plan to a single action;
// a true multi-step plan downgrades to clarify (fast-follow executes it).
const rawSteps = Array.isArray(parsed.steps) ? parsed.steps : [];
const steps = rawSteps.map((s) => buildValidStep(s, ctx)).filter((s): s is ResolvedAction => s !== null);
if (steps.length === 1) return finalizeAction(steps[0], deps.currentTier);
if (steps.length > 1) {
const labels = steps.map((s) => s.label).join(' → ');
return {
kind: 'clarify',
question: `This needs multiple steps: ${labels}. Want me to start with "${steps[0].label}"?`,
options: [`Start: ${steps[0].label}`, 'Cancel'],
steps,
};
}
return { kind: 'none', fallback: true, message: "I couldn't map that to actions I can run." };
}
case 'clarify': {
const question = typeof parsed.question === 'string' ? parsed.question : 'Could you say a bit more about what you want?';
const options = Array.isArray(parsed.options)
? parsed.options.filter((o): o is string => typeof o === 'string').slice(0, 5)
: undefined;
return { kind: 'clarify', question, options };
}
case 'none': {
const message = typeof parsed.message === 'string' ? parsed.message : "I can't do that from here yet.";
return { kind: 'none', message };
}
default:
return FALLBACK;
}
}