blob: d1339eafa1e473bb12133f14ba0be5bd3b7cffac [file]
import { redactSecrets, type PersonalizationSettings } from '@maka/core';
import {
buildPersonalizationPromptFragment,
buildSessionEnvironmentPromptFragment,
buildSkillsPromptFragmentWithReport,
buildWorkspaceInstructionsPromptFragment,
resolveProjectGitInfo,
resolveSkillDiscoveryPaths,
type AutomationManager,
type GoalManager,
type HostCapabilities,
type SkillSource,
type SkillSelectionReport,
} from '@maka/runtime';
/**
* CLI/TUI system-prompt assembly.
*
* The durable system prompt is built from the personalization fragment and the
* gated workspace-instructions fragment (AGENTS.md / CLAUDE.md / GEMINI.md from
* the session cwd). The per-turn tail carries the session environment (cwd /
* git / platform / date), which must stay volatile to avoid churning the system
* prefix hash.
*
* The fragment builders themselves live in @maka/runtime and are shared with the
* desktop app. This module owns only the CLI's choice of which fragments to
* assemble; settings are read by the caller (runtime-bootstrap) and injected
* here so @maka/runtime does not need to depend on @maka/storage.
*/
export interface BuildCliSystemPromptInput {
settings: {
personalization?: Partial<PersonalizationSettings>;
workspaceInstructions: { enabled: boolean };
};
cwd: string;
/**
* Workspace root holding the shared `skills/` directory (distinct from the
* session `cwd` so the project directory is never scanned for skills). The
* skill catalog fragment is built from `{workspaceRoot}/skills/`.
*/
workspaceRoot: string;
/**
* Host capability surface for the skill-compatibility gate. When omitted,
* the catalog is built without gating (legacy behavior). The CLI host
* passes its registered tool names so skills whose `requiredTools` are not
* available (for example, a skill requiring a desktop-only tool) are hidden.
*/
host?: HostCapabilities;
/** Selected model context window used to bound the always-on skill catalog. */
modelContextWindow?: number;
/**
* Home directory for user-level skill discovery (`~/.maka/skills/`,
* `~/.agents/skills/`). Defaults to `os.homedir()`. Tests pass a temp dir
* to avoid picking up real installed skills.
*/
homeDir?: string;
onSkillSelection?: (report: SkillSelectionReport) => void;
}
export async function buildCliSystemPrompt(
input: BuildCliSystemPromptInput,
): Promise<string | undefined> {
const personalization = buildPersonalizationPromptFragment(input.settings.personalization);
// personalization -> skills -> workspaceInstructions, matching the desktop app.
const skillSource = resolveSkillDiscoveryPaths(input.cwd, input.workspaceRoot, input.homeDir);
const skillPrompt = await buildSkillsPromptFragmentWithReport(skillSource, input.host, {
contextWindow: input.modelContextWindow,
});
input.onSkillSelection?.(skillPrompt.report);
const skills = skillPrompt.text;
const workspaceInstructions = input.settings.workspaceInstructions.enabled
? await buildWorkspaceInstructionsPromptFragment(input.cwd)
: undefined;
const fragments = [personalization.text, skills, workspaceInstructions].filter((v): v is string =>
Boolean(v),
);
return fragments.length > 0 ? fragments.join('\n\n') : undefined;
}
export async function buildCliTurnTailPrompt(input: {
cwd: string;
sessionId?: string;
automationManager?: AutomationManager;
goalManager?: GoalManager;
}): Promise<string> {
const projectGit = await resolveProjectGitInfo(input.cwd);
const fragments = [buildSessionEnvironmentPromptFragment({ cwd: input.cwd, projectGit })];
if (input.sessionId && input.automationManager) {
const automationFragment = buildAutomationTailFragment(
input.sessionId,
input.automationManager,
);
if (automationFragment) fragments.push(automationFragment);
}
if (input.sessionId && input.goalManager) {
const goalFragment = buildGoalTailFragment(input.sessionId, input.goalManager);
if (goalFragment) fragments.push(goalFragment);
}
return fragments.join('\n\n');
}
function buildGoalTailFragment(sessionId: string, manager: GoalManager): string | undefined {
const goal = manager.get(sessionId);
if (!goal || (goal.status !== 'active' && goal.status !== 'waiting' && goal.status !== 'paused'))
return undefined;
const spent = Math.max(0, goal.tokensNow - goal.tokensAtStart);
const lines = [
'Active goal (autonomous execution; system evaluates progress each turn):',
'<goal-execution>',
`condition="${redactSecrets(goal.condition)}"`,
`status=${goal.status} turns=${goal.iterations}/${goal.maxIterations} no_progress=${goal.consecutiveNoProgress}/${goal.blockCap}` +
`${goal.tokenBudget ? ` tokens=${spent}/${goal.tokenBudget}` : ''}`,
...(goal.lastReason ? [`last_reason="${redactSecrets(goal.lastReason)}"`] : []),
'</goal-execution>',
];
return lines.join('\n');
}
function buildAutomationTailFragment(
sessionId: string,
manager: AutomationManager,
): string | undefined {
const automations = manager
.listForSession(sessionId)
.filter((a) => a.status === 'active' || a.status === 'paused');
if (automations.length === 0) return undefined;
const lines = [
'Active automations (use Automation tool with mode "list" for full details):',
'<automations>',
...automations.map((a) => {
const schedule =
a.schedule.type === 'cron'
? `cron "${a.schedule.expression}"`
: a.schedule.type === 'interval'
? `every ${a.schedule.seconds}s`
: `once`;
return ` ${a.status} id="${a.id}" name="${a.name}" kind=${a.kind} schedule=${schedule} fires=${a.fireCount}`;
}),
'</automations>',
];
return lines.join('\n');
}