diff --git a/.gitignore b/.gitignore index 90a45e9..24e3933 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .lazyglm/ +.clawhip/ *.log .DS_Store dist/ diff --git a/docs/GLM_NATIVE_PROMPT.md b/docs/GLM_NATIVE_PROMPT.md new file mode 100644 index 0000000..c48cb05 --- /dev/null +++ b/docs/GLM_NATIVE_PROMPT.md @@ -0,0 +1,70 @@ +# GLM-Native Prompt Rationale + +LazyGLM now prepends a GLM-native operating block to both the one-shot runtime +and the interactive REPL prompt. The block is intentionally additive: it keeps +the existing LazyGLM working rules/persona, then gives the model GLM-specific +context about the active catalog tier, context window, `reasoning_content`, and +tool-loop expectations. + +## z.ai References + +- GLM-5.2 model docs: https://docs.z.ai/guides/llm/glm-5.2 + - Used for the prompt emphasis on long-horizon coding, project-scale context, + engineering-standards adherence, 1M context, and 128K maximum output. +- Thinking Mode docs: https://docs.z.ai/guides/capabilities/thinking-mode + - Used for `reasoning_content`, interleaved thinking, preserved thinking, and + the need to keep reasoning continuity across tool turns when the runtime + supplies it. +- GLM Coding Plan Quick Start: https://docs.z.ai/devpack/quick-start + - Used for Coding Plan-specific endpoint/protocol framing and the fact that + Coding Plan setup is a coding-tool experience rather than a generic chat + endpoint. +- Tool Integration URL from issue #44: + https://docs.z.ai/guides/capabilities/tool/others + - Checked during implementation, but this URL was unavailable from the docs + fetcher. This PR therefore avoids unsupported claims about that page and + relies only on the reachable GLM-5.2, Thinking Mode, and Coding Plan pages. + +## Prompt Shape + +The shared prompt builder in `src/prompt.js` creates: + +1. `GLM-NATIVE OPERATING CONTRACT` +2. Existing one-shot working rules or REPL persona +3. Environment block +4. Hook-injected project context +5. Optional caller-supplied runtime extra text + +The new top block tells the model: + +- it is running as LazyGLM on GLM, not as a generic assistant; +- which model and catalog tier are active; +- what the catalog says about the model tier and intended tradeoff; +- to use GLM's long-horizon coding strengths for project context, engineering + constraints, staged execution, and verification; +- to preserve `reasoning_content` continuity when the runtime provides it; +- to inspect with tools, reason over results, and continue with small verified + steps. + +## Catalog Coupling + +Tier guidance is derived from `config/model-catalog.json` through the active +model's `tier`, `context_window`, and `description`. The prompt does not carry a +parallel model list. Unknown custom models degrade to a generic GLM-native block +without invented tier guidance. + +## CLI Surfaces + +The REPL banner and `/status` now accept optional `tier` and `tierReason` +fields: + +- callers that omit tier data keep the previous output shape; +- REPL startup shows active tier and guidance when the catalog has an entry; +- `/status` shows active tier and guidance alongside existing token telemetry, + including reasoning spend. + +## Deferred Scope + +This foundational slice does not implement onboarding education or +`lazyglm doctor` GLM-native configuration checks. Those are separate issue #44 +acceptance criteria with different user-flow and diagnostic test surfaces. diff --git a/src/agent/runtime.js b/src/agent/runtime.js index ea7d7e0..9de02ed 100644 --- a/src/agent/runtime.js +++ b/src/agent/runtime.js @@ -6,13 +6,14 @@ import { join, dirname } from "node:path"; import { appendFile } from "node:fs/promises"; import { chat, resolveProviderConfig, shouldPreserveThinking } from "./provider.js"; -import { detectRole, loadCatalog, resolveContextBudget } from "./router.js"; +import { detectRole, findCatalogModelEntry, loadCatalog, resolveContextBudget } from "./router.js"; import { TOOL_SPECS, TOOL_HANDLERS } from "./tools.js"; import { Context, assistantMessageFrom } from "./context.js"; import { HookEngine } from "../hooks/engine.js"; import { gitInfo, truncate, ensureDir, nowIso } from "../util.js"; import { abortReason, composeAbortSignals, isDeadlineError, throwIfAborted, withAbort } from "./deadline.js"; import { isToolErrorResult } from "./tool-errors.js"; +import { buildRuntimePrompt } from "../prompt.js"; /** * @typedef {import("../types/index.js").ChatUsage} ChatUsage @@ -25,37 +26,8 @@ import { isToolErrorResult } from "./tool-errors.js"; * @typedef {import("../types/index.js").ToolHandler} ToolHandler * * @typedef {{ isRepo: boolean, branch?: string, root?: string }} RuntimeGitInfo - * @typedef {{ cwd: string, git: RuntimeGitInfo, model: string, injects?: string[], extra?: string }} SystemPromptOptions */ -const BASE_SYSTEM_PROMPT = `You are LazyGLM, an autonomous software engineering agent driven by a GLM model. You operate inside a real project directory on the user's machine via tools. - -WORKING RULES -- Think in small, verifiable steps. Read before you write. Prefer patch_file for edits, write_file for new files. -- After making changes, run builds/tests with run_shell to verify. Never claim success without verifying. -- Use grep/list_dir/read_file to orient yourself; do not guess file contents. -- When the task is fully done and verified, call the finish tool once with a concise summary and verification instructions. Do not call finish otherwise. -- Do not narrate at length between tool calls. Act, verify, continue. -- Keep file contents complete and correct — never leave placeholders or TODOs in shipped code. - -You have these tools: read_file, write_file, patch_file, list_dir, grep, run_shell, finish.`; - -/** - * @param {SystemPromptOptions} options - * @returns {string} - */ -function buildSystemPrompt({ cwd, git, model, injects, extra }) { - const parts = [BASE_SYSTEM_PROMPT]; - parts.push( - `\nENVIRONMENT\n- cwd: ${cwd}\n- git: ${git.isRepo ? `${git.branch} @ ${git.root}` : "(not a repo)"}\n- model: ${model}\n- date: ${nowIso()}\n- os: ${process.platform}`, - ); - if (injects && injects.length) { - parts.push(`\nPROJECT CONTEXT (injected by hooks)\n${injects.join("\n\n")}`); - } - if (extra) parts.push(`\n${extra}`); - return parts.join("\n"); -} - /** * Run the GLM agent on a task. * @param {RunAgentOptions} opts @@ -96,7 +68,10 @@ export async function runAgent(opts) { if (!resolvedModel) { throw new Error("No GLM model resolved. Set LAZYGLM_MODEL, pass --model, or configure config/model-catalog.json."); } - const contextBudget = budget ?? resolveContextBudget(providerConfig.model || model || resolvedModel, await loadCatalog()); + const catalog = await loadCatalog(); + const catalogModel = providerConfig.model || model || resolvedModel; + const catalogEntry = findCatalogModelEntry(catalogModel, catalog); + const contextBudget = budget ?? resolveContextBudget(catalogModel, catalog); /** @param {string} message */ const hookLog = (message) => onEvent({ type: "log", message }); @@ -176,7 +151,16 @@ export async function runAgent(opts) { // 1. SessionStart const startRes = await fireRunHook("SessionStart", {}); const gi = gitInfo(cwd); - const system = buildSystemPrompt({ cwd, git: gi, model: resolvedModel, injects: startRes.injects, extra: systemPromptExtra }); + const system = buildRuntimePrompt({ + cwd, + git: gi, + model: resolvedModel, + injects: startRes.injects, + extra: systemPromptExtra, + tier: catalogEntry?.tier, + contextWindow: catalogEntry?.context_window ?? catalogEntry?.context, + description: catalogEntry?.description, + }); ctx.setSystem(system); await log({ type: "system_prompt_chars", chars: system.length }); diff --git a/src/banner.js b/src/banner.js index 520f17e..970bba0 100644 --- a/src/banner.js +++ b/src/banner.js @@ -104,6 +104,10 @@ function centerVisible(s, width) { return " ".repeat(left) + value + " ".repeat(right); } +function cleanSegment(value) { + return String(value ?? "").replace(/[|\r\n]+/g, " ").replace(/\s+/g, " ").trim(); +} + /** One `label value` row, padded to exactly `width` rendered columns. */ function fieldRow(label, value, width) { const labelField = label ? String(label).padEnd(LABEL_WIDTH) : ""; @@ -121,6 +125,8 @@ function fieldRow(label, value, width) { * @param {object} [opts.git] - { isRepo, branch, root } from gitInfo() * @param {object} [opts.session] - { id, ... } from createSession(); omitted => no session line * @param {boolean} [opts.yolo] - render the yolo line when true + * @param {string} [opts.tier] - catalog tier for the active model + * @param {string} [opts.tierReason] - catalog-derived tier guidance * @param {boolean} [opts.isTTY] - true => art + panel; false/undefined => one clean line * @returns {string} the full banner (no console writes, no process reads) */ @@ -131,6 +137,8 @@ export function renderBanner({ git, session, yolo, + tier, + tierReason, isTTY, } = {}) { const m = model ?? "?"; @@ -141,7 +149,10 @@ export function renderBanner({ // Non-TTY: one machine-readable line, no ANSI, no art. if (!tty) { - return `LazyGLM | ${m} | ${p} | ${dir}\n`; + const parts = ["LazyGLM", m, p, dir]; + if (tier) parts.push(`tier=${cleanSegment(tier)}`); + if (tierReason) parts.push(`guidance=${cleanSegment(tierReason)}`); + return `${parts.join(" | ")}\n`; } const out = []; @@ -158,6 +169,8 @@ export function renderBanner({ const b = BOX.tty; const rows = []; rows.push(fieldRow("model", m, PANEL_WIDTH)); + if (tier) rows.push(fieldRow("tier", tier, PANEL_WIDTH)); + if (tierReason) rows.push(fieldRow("guidance", tierReason, PANEL_WIDTH)); rows.push(fieldRow("provider", p, PANEL_WIDTH)); rows.push(fieldRow("cwd", dir, PANEL_WIDTH)); if (g.isRepo && g.branch) rows.push(fieldRow("git", g.branch, PANEL_WIDTH)); diff --git a/src/prompt.js b/src/prompt.js new file mode 100644 index 0000000..355bc9e --- /dev/null +++ b/src/prompt.js @@ -0,0 +1,141 @@ +// @ts-check + +// Side-effect-free prompt composition for the one-shot runtime and interactive +// REPL. Model/tier facts are supplied by callers from config/model-catalog.json. +import { nowIso } from "./util.js"; + +/** + * @typedef {{ isRepo: boolean, branch?: string, root?: string }} PromptGitInfo + * @typedef {object} PromptOptions + * @property {string} cwd + * @property {PromptGitInfo} git + * @property {string} model + * @property {string[]} [injects] + * @property {string} [extra] + * @property {string} [tier] + * @property {number|string} [contextWindow] + * @property {string} [description] + */ + +export const RUNTIME_WORKING_PROMPT = `You are LazyGLM, an autonomous software engineering agent driven by a GLM model. You operate inside a real project directory on the user's machine via tools. + +WORKING RULES +- Think in small, verifiable steps. Read before you write. Prefer patch_file for edits, write_file for new files. +- After making changes, run builds/tests with run_shell to verify. Never claim success without verifying. +- Use grep/list_dir/read_file to orient yourself; do not guess file contents. +- When the task is fully done and verified, call the finish tool once with a concise summary and verification instructions. Do not call finish otherwise. +- Do not narrate at length between tool calls. Act, verify, continue. +- Keep file contents complete and correct - never leave placeholders or TODOs in shipped code. + +You have these tools: read_file, write_file, patch_file, list_dir, grep, run_shell, finish.`; + +export const REPL_PERSONA_PROMPT = `You are LazyGLM, a terminal-based AI coding agent connected directly to the user's file system via a CLI. + +PERSONALITY: +You are a brilliant but "lazy" pragmatic developer. You hate writing unnecessary text, explanations, or filler. You believe code speaks louder than words. You do exactly what is asked, make the edit, and stop talking. Never say "Certainly!" or "I'd be happy to help." Just do the work. Be extremely concise. If the user didn't ask for an explanation, don't give one. + +HOW YOU OPERATE (agentic - you have tools): +- To edit a file, use the patch_file tool (SEARCH/REPLACE: old_string -> new_string). Never output whole files. Never paste SEARCH/REPLACE blocks into chat - invoke the tool. +- To see a file, use read_file / list_dir / grep autonomously. Do NOT ask the user to @mention or paste files - go look yourself. +- After making changes, verify with run_shell (build/test). Never claim success without verifying. +- Keep your terminal output clean and readable. +- When the user's request is fully done, call the finish tool with a one-line summary.`; + +/** @type {Record} */ +const TIER_GUIDANCE = { + "high-end": "Use this tier for long-horizon coding, architecture, complex debugging, and work that benefits from the largest GLM context.", + "high-end-fast": "Use this tier when the task still needs strong coding ability but should spend fewer latency/cost resources than the flagship route.", + strong: "Use this tier for general implementation and review where full flagship context is not required.", + balanced: "Use this tier for verification, medium-complexity changes, and routine coding turns.", + fast: "Use this tier for quick edits, listings, simple lookups, and low-latency helper work.", +}; + +/** + * @param {{ tier?: string, description?: string }} [info] + * @returns {string} + */ +export function modelTierGuidance(info = {}) { + const tier = info.tier || ""; + const tierText = TIER_GUIDANCE[tier] || ""; + const description = info.description ? String(info.description).trim() : ""; + if (tierText && description) return `${tierText} Catalog note: ${description}`; + return tierText || description; +} + +/** + * @param {number|string|undefined} value + * @returns {string} + */ +function formatContextWindow(value) { + const n = Number(value); + if (Number.isFinite(n) && n > 0) return n.toLocaleString("en-US"); + return value ? String(value) : "catalog entry unavailable"; +} + +/** + * @param {{ model?: string, tier?: string, contextWindow?: number|string, description?: string }} [options] + * @returns {string} + */ +export function buildGlmNativeBlock({ model, tier, contextWindow, description } = {}) { + const guidance = modelTierGuidance({ tier, description }); + const lines = [ + "GLM-NATIVE OPERATING CONTRACT", + `- You are running on GLM through LazyGLM. Treat this as a GLM-native coding-agent session, not a generic assistant session.`, + `- Active model: ${model || "unknown"}${tier ? ` (${tier})` : ""}. Context window: ${formatContextWindow(contextWindow)} tokens.`, + ]; + if (guidance) lines.push(`- Tier guidance: ${guidance}`); + lines.push( + "- Play to GLM's documented strengths: preserve long-horizon project context, carry forward engineering constraints, and close tasks through implementation plus verification.", + "- GLM thinking may arrive as reasoning_content. Preserve reasoning continuity across tool turns when the runtime supplies it, but keep user-facing summaries concise and avoid exposing raw hidden reasoning unless the UI explicitly streams it.", + "- Follow z.ai Coding Plan tool-loop conventions: inspect with tools, reason over tool results before the next action, then continue with the smallest verified step.", + ); + return lines.join("\n"); +} + +/** + * @param {PromptOptions} options + * @returns {string} + */ +function buildEnvironmentBlock({ cwd, git, model }) { + return [ + "ENVIRONMENT", + `- cwd: ${cwd}`, + `- git: ${git?.isRepo ? `${git.branch} @ ${git.root}` : "(not a repo)"}`, + `- model: ${model}`, + `- date: ${nowIso()}`, + `- os: ${process.platform}`, + ].join("\n"); +} + +/** + * @param {PromptOptions} options + * @returns {string} + */ +export function buildRuntimePrompt(options) { + const parts = [ + buildGlmNativeBlock(options), + RUNTIME_WORKING_PROMPT, + buildEnvironmentBlock(options), + ]; + if (options.injects && options.injects.length) { + parts.push(`PROJECT CONTEXT (injected by hooks)\n${options.injects.join("\n\n")}`); + } + if (options.extra) parts.push(options.extra); + return parts.join("\n\n"); +} + +/** + * @param {PromptOptions} options + * @returns {string} + */ +export function buildReplPrompt(options) { + const parts = [ + buildGlmNativeBlock(options), + REPL_PERSONA_PROMPT, + buildEnvironmentBlock(options), + ]; + if (options.injects && options.injects.length) { + parts.push(`PROJECT CONTEXT (injected by hooks)\n${options.injects.join("\n\n")}`); + } + return parts.join("\n\n"); +} diff --git a/src/repl.js b/src/repl.js index c04f040..116d152 100644 --- a/src/repl.js +++ b/src/repl.js @@ -7,7 +7,7 @@ import * as readline from "node:readline"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { chat, resolveProviderConfig, shouldPreserveThinking } from "./agent/provider.js"; -import { loadCatalog, resolveContextBudget } from "./agent/router.js"; +import { findCatalogModelEntry, loadCatalog, resolveContextBudget } from "./agent/router.js"; import { beginAdaptiveUserTurn, createAdaptiveRoutingState, @@ -30,13 +30,10 @@ import { runOnboarding, needsOnboarding } from "./onboard.js"; import { createSession, appendEvent, listSessions, loadSessionEvents, lastSession } from "./sessions.js"; import { install } from "./installer.js"; import { runUltrawork } from "./ulw.js"; -import { readJson, gitInfo, truncate, nowIso } from "./util.js"; +import { gitInfo, truncate } from "./util.js"; import { renderBanner } from "./banner.js"; import { renderStatus } from "./status.js"; -import { fileURLToPath } from "node:url"; -import { dirname, join as pjoin } from "node:path"; - -const ROOT = pjoin(dirname(fileURLToPath(import.meta.url)), ".."); +import { buildReplPrompt, modelTierGuidance } from "./prompt.js"; const GRAY = "\x1b[90m"; const DIM = "\x1b[2m"; @@ -187,18 +184,6 @@ export function hasManualRoutingOverride(flags = {}) { return !!(flags.model || flags.role); } -const REPL_PERSONA = `You are LazyGLM, a terminal-based AI coding agent connected directly to the user's file system via a CLI. - -PERSONALITY: -You are a brilliant but "lazy" pragmatic developer. You hate writing unnecessary text, explanations, or filler. You believe code speaks louder than words. You do exactly what is asked, make the edit, and stop talking. Never say "Certainly!" or "I'd be happy to help." Just do the work. Be extremely concise. If the user didn't ask for an explanation, don't give one. - -HOW YOU OPERATE (agentic — you have tools): -- To edit a file, use the patch_file tool (SEARCH/REPLACE: old_string → new_string). Never output whole files. Never paste SEARCH/REPLACE blocks into chat — invoke the tool. -- To see a file, use read_file / list_dir / grep autonomously. Do NOT ask the user to @mention or paste files — go look yourself. -- After making changes, verify with run_shell (build/test). Never claim success without verifying. -- Keep your terminal output clean and readable. -- When the user's request is fully done, call the finish tool with a one-line summary.`; - /** * A single readline interface over stdin that buffers lines and serves them * sequentially via next(). This is shared by onboarding and the REPL so that @@ -284,15 +269,6 @@ function renderDelta(d) { } } -function buildSystemPrompt({ cwd, git, model, injects }) { - const parts = [REPL_PERSONA]; - parts.push( - `\nENVIRONMENT\n- cwd: ${cwd}\n- git: ${git.isRepo ? `${git.branch} @ ${git.root}` : "(not a repo)"}\n- model: ${model}\n- date: ${nowIso()}\n- os: ${process.platform}`, - ); - if (injects && injects.length) parts.push(`\nPROJECT CONTEXT (injected by hooks)\n${injects.join("\n\n")}`); - return parts.join("\n"); -} - function extractQuoted(s) { const m = s.match(/"([^"]*)"/); return m ? m[1] : null; @@ -460,8 +436,9 @@ export async function launchREPL({ cwd, flags = {} } = {}) { process.exit(1); } let currentModel = providerConfig.modelId; + const catalog = await loadCatalog(); let manualContextBudget = Number.isInteger(flags.contextBudget) && flags.contextBudget > 0 ? flags.contextBudget : null; - let contextBudget = manualContextBudget ?? resolveContextBudget(providerConfig.model, await loadCatalog()); + let contextBudget = manualContextBudget ?? resolveContextBudget(providerConfig.model, catalog); const adaptiveState = createAdaptiveRoutingState({ manualOverride: hasManualRoutingOverride(flags) }); // 3. Auto-init the project dir silently (.lazyglm/ + AGENTS.md) if missing @@ -481,8 +458,27 @@ export async function launchREPL({ cwd, flags = {} } = {}) { const gi = gitInfo(dir); const ctx = new Context({ model: currentModel, budget: contextBudget, preserveThinking: shouldPreserveThinking(providerConfig.provider) }); const startRes = await engine.fire("SessionStart", {}); - const system = buildSystemPrompt({ cwd: dir, git: gi, model: currentModel, injects: startRes.injects }); - ctx.setSystem(system); + const activeModelInfo = () => { + const entry = findCatalogModelEntry(providerConfig.model || currentModel, catalog) || findCatalogModelEntry(currentModel, catalog); + const tier = entry?.tier; + const description = entry?.description; + const contextWindow = entry?.context_window ?? entry?.context; + const tierReason = modelTierGuidance({ tier, description }); + return { tier, description, contextWindow, tierReason }; + }; + const refreshSystemPrompt = () => { + const info = activeModelInfo(); + ctx.setSystem(buildReplPrompt({ + cwd: dir, + git: gi, + model: currentModel, + injects: startRes.injects, + tier: info.tier, + contextWindow: info.contextWindow, + description: info.description, + })); + }; + refreshSystemPrompt(); let yolo = !!flags.yolo; engine.setMeta({ model: currentModel, transcriptPath: null, permissionMode: yolo ? "yolo" : "auto" }); @@ -540,26 +536,27 @@ export async function launchREPL({ cwd, flags = {} } = {}) { git: gi, session, yolo, + ...activeModelInfo(), isTTY: process.stdout.isTTY ?? false, }), ); const resolveRoutingCandidate = async (role) => { - const catalog = await loadCatalog(); const config = await resolveProviderConfig({ provider: flags.provider, role }); return { config, bundle: effectiveBundleFromProviderConfig(config, catalog) }; }; - const currentRoutingBundle = async () => effectiveBundleFromProviderConfig(providerConfig, await loadCatalog()); + const currentRoutingBundle = async () => effectiveBundleFromProviderConfig(providerConfig, catalog); const applyRoutingDecision = async (decision, nextConfig) => { if (!decision) return false; providerConfig = nextConfig; currentModel = nextConfig.modelId; - contextBudget = manualContextBudget ?? resolveContextBudget(nextConfig.model, await loadCatalog()); + contextBudget = manualContextBudget ?? resolveContextBudget(nextConfig.model, catalog); ctx.model = currentModel; ctx.budget = contextBudget; ctx.preserveThinking = shouldPreserveThinking(nextConfig.provider); + refreshSystemPrompt(); engine.setMeta({ model: currentModel }); recordRoutingApplied(adaptiveState, decision); process.stdout.write(`${formatRoutingNotice(decision, renderOpts())}\n`); @@ -647,24 +644,26 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); case "model": { if (!argStr) { console.log(` current model: ${currentModel}`); - const cat = await readJson(pjoin(ROOT, "config", "model-catalog.json"), {}); - console.log(` available: ${Object.keys(cat.models || {}).join(", ")}`); + console.log(` available: ${Object.keys(catalog.models || {}).join(", ")}`); return; } try { const nc = await resolveProviderConfig({ model: argStr, provider: flags.provider, role: "default" }); providerConfig = nc; currentModel = nc.modelId; - contextBudget = manualContextBudget ?? resolveContextBudget(nc.model, await loadCatalog()); + contextBudget = manualContextBudget ?? resolveContextBudget(nc.model, catalog); ctx.model = currentModel; ctx.budget = contextBudget; // A /model switch can change the provider (e.g. zai → ollama), which // flips whether reasoning_content is on the wire. Keep the budget // estimator in sync so compaction decisions match the new payload. ctx.preserveThinking = shouldPreserveThinking(nc.provider); + refreshSystemPrompt(); engine.setMeta({ model: currentModel }); resetAdaptiveRoutingState(adaptiveState, { manualOverride: true }); - console.log(`${GREEN} ✓ model: ${currentModel}${RESET}`); + const info = activeModelInfo(); + const tierNote = info.tier ? ` | tier: ${info.tier}${info.tierReason ? ` - ${info.tierReason}` : ""}` : ""; + console.log(`${GREEN} ✓ model: ${currentModel}${tierNote}${RESET}`); } catch (e) { console.log(`${YELLOW} ✗ ${e.message}${RESET}`); } @@ -673,7 +672,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); case "context-budget": { const res = resolveContextBudgetCommand(argStr, { model: providerConfig.model, - catalog: await loadCatalog(), + catalog, manualBudget: manualContextBudget, }); if (res.error) { @@ -697,13 +696,9 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); // is reflected without threading effort through providerConfig's return. let effort = "high"; let role = providerConfig.role || "default"; - try { - const cat = await readJson(pjoin(ROOT, "config", "model-catalog.json"), {}); - const roleEntry = cat.roles?.[role] || cat.roles?.default || {}; - effort = roleEntry.reasoning_effort || cat.current?.model_reasoning_effort || "high"; - } catch { - // catalog read failure is non-fatal for a status line - } + const roleEntry = catalog.roles?.[role] || catalog.roles?.default || {}; + effort = roleEntry.reasoning_effort || catalog.current?.model_reasoning_effort || "high"; + const info = activeModelInfo(); console.log( renderStatus({ sessionId: session?.id, @@ -711,6 +706,8 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); provider: providerConfig.provider, role, reasoningEffort: effort, + tier: info.tier, + tierReason: info.tierReason, cumulative, lastTurn, sessionElapsedMs: Date.now() - sessionStartMs, @@ -786,16 +783,15 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); // authoritative and must be honored — resolving the catalog ultrabrain here // would override their selection. Only fall back to catalog ultrabrain when // adaptive routing is active and may have de-escalated the live route. - const ultraCatalog = await loadCatalog(); let ultraConfig, ultraBundle, ultraBudget; if (adaptiveState.manualOverride) { ultraConfig = providerConfig; - ultraBundle = effectiveBundleFromProviderConfig(ultraConfig, ultraCatalog); + ultraBundle = effectiveBundleFromProviderConfig(ultraConfig, catalog); ultraBudget = contextBudget; } else { ultraConfig = await resolveProviderConfig({ provider: flags.provider, role: "ultrabrain" }); - ultraBundle = effectiveBundleFromProviderConfig(ultraConfig, ultraCatalog); - ultraBudget = manualContextBudget ?? resolveContextBudget(ultraConfig.model, ultraCatalog); + ultraBundle = effectiveBundleFromProviderConfig(ultraConfig, catalog); + ultraBudget = manualContextBudget ?? resolveContextBudget(ultraConfig.model, catalog); } console.log(`\n${CYAN}🔁 ULTRAWORK${RESET} — task: ${truncate(task, 120)}`); if (verifyCommand) console.log(` verify: ${verifyCommand}`); diff --git a/src/status.js b/src/status.js index 31b1451..dcbe315 100644 --- a/src/status.js +++ b/src/status.js @@ -24,6 +24,15 @@ const DIM = "\x1b[2m"; const ANSI_RE = /\x1b\[[0-9;]*m/g; +function cleanSegment(value) { + return String(value ?? "").replace(/[|\r\n]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function truncateToChars(value, max = 96) { + const text = cleanSegment(value); + return text.length > max ? text.slice(0, Math.max(0, max - 1)) + "…" : text; +} + /** Human-readable duration for the TTY line (e.g. "2m13s", "4.2s", "0ms"). */ function humanizeMs(ms) { const n = Number(ms) || 0; @@ -47,6 +56,8 @@ function humanizeMs(ms) { * @param {string} [opts.provider] - active provider (e.g. "zai") * @param {string} [opts.role] - routing role (e.g. "default") * @param {string} [opts.reasoningEffort] - "high"|"low"|... from the catalog role + * @param {string} [opts.tier] - catalog tier for the active model + * @param {string} [opts.tierReason] - catalog-derived tier guidance * @param {object} [opts.cumulative] - { prompt, completion, reasoning } running totals * @param {object} [opts.lastTurn] - { prompt, completion, reasoning } for the last turn * @param {number} [opts.sessionElapsedMs] - wall-clock ms since session start @@ -60,6 +71,8 @@ export function renderStatus({ provider, role, reasoningEffort, + tier, + tierReason, cumulative, lastTurn, sessionElapsedMs, @@ -85,12 +98,16 @@ export function renderStatus({ `provider=${p}`, `role=${r}`, `effort=${effort}`, + ]; + if (tier) parts.push(`tier=${cleanSegment(tier)}`); + if (tierReason) parts.push(`tier_reason=${cleanSegment(tierReason)}`); + parts.push( `turn_ms=${lastTurnMs != null ? Math.max(0, Math.round(Number(lastTurnMs) || 0)) : ""}`, `session_ms=${Math.max(0, Math.round(Number(sessionElapsedMs) || 0))}`, `prompt=${c.prompt || 0}`, `completion=${c.completion || 0}`, `reasoning=${c.reasoning || 0}`, - ]; + ); if (lt) { parts.push(`last_prompt=${lt.prompt || 0}`); parts.push(`last_completion=${lt.completion || 0}`); @@ -106,6 +123,7 @@ export function renderStatus({ `${GRAY} ${sid}${RESET} ${DIM}|${RESET} ` + `${CYAN}${m}${RESET} ${DIM}·${RESET} ${p} ${DIM}|${RESET} ` + `${DIM}role${RESET} ${r}/${effort} ${DIM}|${RESET} ` + + (tier ? `${DIM}tier${RESET} ${tier}${tierReason ? `: ${truncateToChars(tierReason)}` : ""} ${DIM}|${RESET} ` : "") + `${GRAY}⏱${RESET} turn ${tElapsed} ${DIM}·${RESET} session ${sElapsed} ${DIM}|${RESET} ` + `${DIM}tok${RESET} ${c.prompt || 0}↑/${c.completion || 0}↓ (${GRAY}🧠 ${c.reasoning || 0}${RESET}) ${DIM}|${RESET} ` + `${GRAY}credits: n/a${RESET}`; diff --git a/test/banner.test.mjs b/test/banner.test.mjs index a9f098b..cde4dba 100644 --- a/test/banner.test.mjs +++ b/test/banner.test.mjs @@ -67,6 +67,36 @@ test("non-TTY: zero ANSI, a single machine-readable line", () => { assert.equal(out.split("\n").filter(Boolean).length, 1, "non-TTY output is exactly one non-empty line"); }); +test("tier: omitted tier keeps legacy non-TTY output byte-identical", () => { + const out = renderBanner({ ...base, isTTY: false }); + assert.equal(out, "LazyGLM | glm-5.2 | zai | /tmp/demo\n"); +}); + +test("tier: TTY banner shows active tier and catalog-derived guidance", () => { + const out = renderBanner({ + ...base, + tier: "high-end", + tierReason: "Use this tier for long-horizon coding.", + isTTY: true, + }); + const plain = stripAnsi(out); + assert.ok(plain.includes("tier high-end"), "tier row present"); + assert.ok(plain.includes("guidance Use this tier for long-horizon coding."), "guidance row present"); +}); + +test("tier: non-TTY banner appends pipe-parseable tier fields only when supplied", () => { + const out = renderBanner({ + ...base, + tier: "high-end", + tierReason: "Use this tier for long-horizon coding.", + isTTY: false, + }); + assert.equal( + out, + "LazyGLM | glm-5.2 | zai | /tmp/demo | tier=high-end | guidance=Use this tier for long-horizon coding.\n", + ); +}); + test("non-TTY: stays a clean single line even with git/session/yolo set", () => { const out = renderBanner({ model: "glm-5.2", diff --git a/test/prompt.test.mjs b/test/prompt.test.mjs new file mode 100644 index 0000000..0418cf0 --- /dev/null +++ b/test/prompt.test.mjs @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildGlmNativeBlock, + buildReplPrompt, + buildRuntimePrompt, + modelTierGuidance, +} from "../src/prompt.js"; + +const git = { isRepo: true, branch: "main", root: "/tmp/demo" }; +const modelInfo = { + model: "glm-5.2", + tier: "high-end", + contextWindow: 1000000, + description: "High-end GLM coding model. Use for hard reasoning, architecture, complex debugging.", +}; + +test("GLM-native block names the active model, tier, context, reasoning_content, and z.ai tool loop", () => { + const block = buildGlmNativeBlock(modelInfo); + assert.match(block, /^GLM-NATIVE OPERATING CONTRACT/); + assert.match(block, /glm-5\.2 \(high-end\)/); + assert.match(block, /1,000,000 tokens/); + assert.match(block, /reasoning_content/); + assert.match(block, /z\.ai Coding Plan tool-loop/); +}); + +test("runtime prompt prepends the GLM-native block and preserves working rules", () => { + const prompt = buildRuntimePrompt({ + cwd: "/tmp/demo", + git, + ...modelInfo, + injects: ["repo rule"], + extra: "EXTRA_RUNTIME_RULE", + }); + assert.ok(prompt.startsWith("GLM-NATIVE OPERATING CONTRACT")); + assert.ok(prompt.indexOf("GLM-NATIVE OPERATING CONTRACT") < prompt.indexOf("WORKING RULES")); + assert.match(prompt, /You have these tools: read_file, write_file, patch_file, list_dir, grep, run_shell, finish\./); + assert.match(prompt, /PROJECT CONTEXT \(injected by hooks\)\nrepo rule/); + assert.match(prompt, /EXTRA_RUNTIME_RULE/); +}); + +test("REPL prompt prepends GLM-native behavior and preserves LazyGLM terminal persona", () => { + const prompt = buildReplPrompt({ + cwd: "/tmp/demo", + git, + ...modelInfo, + injects: ["AGENTS.md rule"], + }); + assert.ok(prompt.startsWith("GLM-NATIVE OPERATING CONTRACT")); + assert.ok(prompt.indexOf("GLM-NATIVE OPERATING CONTRACT") < prompt.indexOf("PERSONALITY:")); + assert.match(prompt, /terminal-based AI coding agent/); + assert.match(prompt, /PROJECT CONTEXT \(injected by hooks\)\nAGENTS\.md rule/); +}); + +test("tier guidance is derived from catalog tier plus catalog description", () => { + const guidance = modelTierGuidance({ + tier: "fast", + description: "Fast, efficient GLM for quick edits, listings, sub-agents. Lowest cost/latency.", + }); + assert.match(guidance, /quick edits/); + assert.match(guidance, /Catalog note: Fast, efficient GLM/); +}); + +test("unknown catalog entries degrade without inventing model guidance", () => { + assert.equal(modelTierGuidance({}), ""); + assert.equal(modelTierGuidance({ description: "Custom GLM-compatible endpoint." }), "Custom GLM-compatible endpoint."); + const block = buildGlmNativeBlock({ model: "custom-glm" }); + assert.match(block, /custom-glm/); + assert.match(block, /catalog entry unavailable/); +}); diff --git a/test/status.test.mjs b/test/status.test.mjs index afcb697..de4c570 100644 --- a/test/status.test.mjs +++ b/test/status.test.mjs @@ -69,6 +69,42 @@ test("non-TTY status line is pipe-parseable key=value", () => { assert.equal(kv.credits, "unsupported", "non-TTY credits show the unsupported state"); }); +test("tier: omitted tier keeps existing non-TTY key set unchanged", () => { + const out = renderStatus({ ...base, isTTY: false }); + assert.doesNotMatch(out, /tier=/); + assert.doesNotMatch(out, /tier_reason=/); +}); + +test("tier: non-TTY status includes tier and guidance alongside reasoning spend", () => { + const out = renderStatus({ + ...base, + tier: "balanced", + tierReason: "Use this tier for verification and medium-complexity work.", + isTTY: false, + }); + const kv = Object.fromEntries( + out + .split(" | ") + .slice(1) + .map((seg) => seg.split("=")), + ); + assert.equal(kv.tier, "balanced"); + assert.equal(kv.tier_reason, "Use this tier for verification and medium-complexity work."); + assert.equal(kv.reasoning, "90", "existing reasoning spend remains visible"); +}); + +test("tier: TTY status includes active tier and guidance", () => { + const out = renderStatus({ + ...base, + tier: "high-end", + tierReason: "Use this tier for long-horizon coding.", + isTTY: true, + }); + const plain = stripAnsi(out); + assert.ok(plain.includes("tier high-end: Use this tier for long-horizon coding."), "tier guidance present"); + assert.ok(plain.includes("🧠 90"), "reasoning spend still present"); +}); + test("credits: always n/a (TTY) / unsupported (non-TTY) — never estimated or faked", () => { const tty = renderStatus({ ...base, isTTY: true }); assert.ok(stripAnsi(tty).includes("credits: n/a"), "TTY credits render n/a");