UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

415 lines (414 loc) • 24.5 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Shared prompt builders for proposal-producing agent commands (#226). * * `akm reflect` and `akm proposal new` both ask the configured engine for a * structured proposal payload. The prompts are intentionally similar and share * their construction here. `proposal new` asks every engine kind for the JSON * object below, with {@link PROPOSAL_JSON_SCHEMA} as the request's output * schema. Reflect asks every engine kind for its own contract, JSON Schema or * framed markdown ({@link ReflectOutputMode}). * * The output an engine must produce for `proposal new` is a strict JSON object: * * ```json * { * "ref": "lessons/my-lesson", * "content": "---\ndescription: ...\nwhen_to_use: ...\n---\n\nbody", * "frontmatter": { "description": "...", "when_to_use": "..." } * } * ``` * * `frontmatter` is optional — the proposal queue parses it from `content` * during validation. We carry it through if the agent supplies it. */ import reflectFeedbackFraming from "../../assets/prompts/reflect-feedback-framing.md" with { type: "text" }; import reflectLlmFramedContract from "../../assets/prompts/reflect-llm-framed-contract.md" with { type: "text" }; import reflectLlmSchemaContract from "../../assets/prompts/reflect-llm-schema-contract.md" with { type: "text" }; import reflectOutputRepair from "../../assets/prompts/reflect-output-repair.md" with { type: "text" }; import { placementTypes } from "../../core/asset/asset-placement.js"; import { parseRefInput } from "../../core/asset/resolve-ref.js"; import { authoringRulesForType, DESCRIPTION_MAX_CHARS, DESCRIPTION_MIN_CHARS, requiresDescription, } from "../../core/authoring-rules.js"; import { isRecord } from "../../core/common.js"; import { parseEmbeddedJsonResponse, stripCodeFences, stripThinkBlocks } from "../../core/parse.js"; /** * Per-asset-type frontmatter / authoring hints surfaced in the prompt so * the agent can produce content that passes proposal validation. Kept tiny: * full schema docs live in `docs/` — these are nudges, not contracts. */ const TYPE_HINTS = { lesson: "lesson assets MUST start with frontmatter containing `description` and `when_to_use` keys (both non-empty). Body: 1–3 short paragraphs of practical guidance. A lesson is NOT a restatement of the source asset — it answers: When should I reach for this? What goes wrong without it? What did real use reveal that the asset itself doesn't say?", skill: "skill assets are stored as `skills/<name>/SKILL.md`. Frontmatter typically includes `name`, `description`, and `when_to_use`.", command: "command assets are markdown with optional frontmatter (`name`, `description`). The body is the prompt template the user invokes.", agent: "agent assets are markdown with frontmatter describing the agent role (`name`, `description`, optional `tools`, `model`).", knowledge: "knowledge assets are reference markdown documents. Include a top-level `# Title` and concise sections.", memory: "memory assets are short factual notes the user wants persisted across sessions. Frontmatter usually includes `description`.", workflow: "workflow assets are markdown describing a multi-step process. Include `# <Title>` and ordered `## Step N` sections.", script: "script assets are executable text files. Include a shebang and minimal usage comment.", env: "env assets are `.env` files holding a group of related CONFIGURATION for an app/service (KEY=VALUE pairs, `#` comments) — URLs, flags, and any credentials it needs. Values may or may not be sensitive; all are protected (key names discoverable, values stay on disk). Inject with `akm env run env/<name> -- <cmd>`; prefer `--clean` in agent contexts so the child starts from a minimal inherited environment. AKM itself does not print values, but the child command can print its environment, so do not run `env`, `printenv`, shell tracing, or similar diagnostics when secrets are in scope. For a single sensitive value used on its own for authentication (token, key, cert) use a `secret` instead. Never echo values back to the user.", wiki: "wiki assets are markdown reference pages with `# Title` and structured headings.", fact: "fact assets are durable stash-level facts (personal/team/project details, coding conventions, stash-meta). Frontmatter SHOULD include `description` and a `category` (personal|team|project|convention|meta); set `pinned: true` only for the small always-injected core. Keep each fact short, high-signal, and self-contained — it is durable context, not an episodic note.", }; function hintForType(type) { return TYPE_HINTS[type] ?? `assets of type "${type}" — produce sensible markdown with optional frontmatter.`; } function knownTypeList() { return [...placementTypes()].sort().join(", "); } /** * Default cap (in characters) on the asset content injected into * {@link buildReflectPrompt}, and the floor a caller-supplied * {@link ReflectPromptInput.contentBudgetChars} is never allowed to go below. * Exists to stay well under OS ARG_MAX when the prompt is passed as a CLI * argument to opencode/claude — large assets (wiki snapshots, long runbooks) * would otherwise trigger E2BIG on posix_spawn. Agent/SDK runners always use * this flat value; the direct-LLM path can raise it per #952 (see the reflect * dispatch site in `src/commands/improve/reflect.ts`). */ export const REFLECT_CONTENT_CAP = 12_000; /** * Marker appended to truncated asset content when it exceeds the active * content budget (#952). Exported so the proposal validators can refuse a body * that still carries it. */ export const REFLECT_TRUNCATION_MARKER = "... [truncated — focus on the visible portion]"; /** * The JSON envelope `proposal new` asks the engine to honour. The wrapper code * uses `JSON.parse(stdout)` to extract the payload — anything outside the JSON * object will be treated as a parse error. */ const RESPONSE_CONTRACT_JSON = [ "Respond ONLY with a single JSON object. No prose before or after.", // Slash conceptId, not the retired `<type>:<name>` colon grammar (Q-02/Q-08). // This contract is the schema an agent copies from, so a stale spelling here // teaches every proposal to use a ref form the CLI now rejects outright. 'Shape: {"ref": "<type>/<name>", "content": "<full file contents>", "frontmatter": {...}, "confidence": <number 0..1>}', "`content` is the full file body that will be written if accepted.", "`frontmatter` is optional — include it if `content` starts with `---` so reviewers can sanity-check the keys.", "`confidence` is REQUIRED. Self-rate this proposal on [0, 1] by how certain you are it materially improves the source asset. Calibrate honestly:", " • 0.90+ — high certainty: fixes a real defect or adds load-bearing missing content; a reviewer would clearly accept.", " • 0.70–0.89 — clear improvement, but a reviewer might reasonably prefer different framing or scope.", " • 0.50–0.69 — marginal / judgment call; might help, might not be worth the churn.", " • Below 0.50 — you are not confident this improves on the source. Prefer returning the source body roughly unchanged with a low score over inventing changes.", "Reviewers and the triage judge read this score when adjudicating the proposal queue. Overclaiming erodes trust in your proposals; underclaiming buries good ones. Be honest.", ].join("\n"); /** * The JSON Schema of {@link RESPONSE_CONTRACT_JSON}'s object, the output * schema `proposal new` sends with its request: an LLM engine gets it as * `response_format`, codex as `--output-schema`, every agent engine as the * shared schema instruction. It is in the strict form those native channels * accept (every property required, no others), so it leaves out the optional * `frontmatter`, which `content` already carries. The reply is held only to * what {@link validateProposalPayload} needs. */ export const PROPOSAL_JSON_SCHEMA = { type: "object", required: ["ref", "content", "confidence"], additionalProperties: false, properties: { ref: { type: "string", description: "The new asset's ref as a subdir-qualified conceptId." }, content: { type: "string", description: "The full file contents that will be written if accepted." }, confidence: { type: "number", minimum: 0, maximum: 1, description: "Self-rated confidence in [0, 1]." }, }, }; export function reflectResponseContract(mode, targetScoped) { if (mode === "json_schema") { return reflectLlmSchemaContract .replace("{{FIELD_RULE}}", targetScoped ? "The response has exactly the required fields `confidence` and `frontmatterPatch`; do not echo `ref` or arbitrary `frontmatter`." : "The response has exactly the required fields `ref`, `confidence`, and `frontmatterPatch`; `ref` must identify the selected asset.") .trim(); } const refLine = targetScoped ? "" : "AKM_REFLECT_REF: <selected asset ref>\n"; return reflectLlmFramedContract.replace("{{REF_LINE}}", refLine).trim(); } export function buildReflectOutputRepairPrompt(mode, targetScoped) { return reflectOutputRepair.replace("{{OUTPUT_CONTRACT}}", reflectResponseContract(mode, targetScoped)).trim(); } /** * Whether the source asset content has a non-empty `description:` key in its * YAML frontmatter. Used by {@link buildReflectPrompt} (#636) to decide whether * to inject the synthesize-a-description instruction. Uses an inline regex to * avoid pulling the full YAML parser into the prompt module (mirrors the * existing inline frontmatter handling here). */ function sourceHasNonEmptyDescription(assetContent) { if (!assetContent) return false; const fmMatch = assetContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!fmMatch) return false; const fmBlock = fmMatch[1] ?? ""; // Match a top-level `description:` line and capture its inline value. const descMatch = fmBlock.match(/^description\s*:\s*(.*)$/m); if (!descMatch) return false; const value = (descMatch[1] ?? "") .trim() .replace(/^['"]|['"]$/g, "") .trim(); return value.length > 0; } /** * The frontmatter problems akm can see for itself, named in the prompt so the * model fixes them instead of having to spot them: a description split by a * stray period or carrying an escaped quote, no `when_to_use`, no title. A * missing description has its own instruction (#636). */ function frontmatterProblems(assetContent) { const fm = assetContent?.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!assetContent || !fm) return []; const block = fm[1] ?? ""; const problems = []; const raw = block .match(/^description\s*:(.*(?:\r?\n[ \t]+.*)*)/m)?.[1] ?.replace(/\s+/g, " ") .trim() ?? ""; const split = [...raw.matchAll(/[\w`)\]]\. [a-z]/g)].find((m) => !/\b(?:e\.g|i\.e|vs|etc|cf)$/i.test(raw.slice(0, (m.index ?? 0) + 1))); if (split) { problems.push(`the \`description\` is broken: a stray period splits a sentence ("${raw}"); repair only the break, keeping its wording and every name, number, path and status word in it`); } else if (raw.includes('\\"')) { problems.push("the `description` is broken by an escaped quote; repair only the quoting, keeping the rest of its wording"); } if (!/^when_to_use\s*:\s*(?:\S|\r?\n[ \t]+\S)/m.test(block)) { problems.push("there is no `when_to_use`: write one, a single sentence the body supports, saying when to reach for this asset"); } if (!/^title\s*:\s*\S/m.test(block) && !/^#[ \t]+\S/m.test(assetContent.slice(fm[0].length))) { problems.push("the body has no level-1 title: give one in `title`"); } return problems; } /** * Build the prompt for `akm reflect [ref]`. Asks the agent to check an * existing asset's `description`, `when_to_use` and title against its body * (plus any negative feedback / lint findings) and return the fields that * need a fix. Returns a {@link ReflectPromptResult} containing the prompt * string. */ export function buildReflectPrompt(input) { const sections = []; if (input.ref && input.type && input.name) { sections.push(`Your task is to check this ${input.type} asset's \`description\`, \`when_to_use\` and title against its body and the feedback below, and fix any that is missing, broken, or claims something the body does not cover. AKM keeps the body exactly as it is: you change only these fields, and when none needs a change you return null for each.`); sections.push(`Target ref: ${input.ref}`); sections.push(`Asset-type guidance: ${hintForType(input.type)}`); } else { sections.push("You are reviewing recent akm feedback and proposing a single improved asset revision."); sections.push("No target ref was supplied. Choose the best target from the feedback below and return it in `ref`."); sections.push(`Known asset types: ${knownTypeList()}.`); } if (input.task?.trim()) { sections.push(`Task / focus: ${input.task.trim()}`); } // Change 3 & 4 — feedback moved before asset content; missing else branch added if (input.feedback && input.feedback.length > 0) { // #952 — feedback lines are unverified reports, not verified facts. Without // this caveat, models fabricated whole new sections asserting whatever a // feedback line claimed (invented incident dates, ports, disk layouts). sections.push(reflectFeedbackFraming.trim()); sections.push("Recent feedback / signals:"); for (const line of input.feedback) sections.push(`- ${line}`); } else if (!input.ref) { sections.push("Recent feedback / signals:"); sections.push("- (no feedback events recorded)"); } else { // ref is set but no feedback — explicitly constrain scope to broken or missing fields sections.push("No usage feedback recorded. Fix only a missing or broken `description`, `when_to_use` or title; otherwise return null for each."); } if (input.standardsContext?.trim()) { sections.push("Standards to follow (the rulebook for this target):"); sections.push(input.standardsContext.trim()); } { const resolvedType = input.type ?? (input.ref ? parseRefInput(input.ref).type : ""); const authoringRules = resolvedType ? authoringRulesForType(resolvedType) : ""; if (authoringRules) { sections.push(authoringRules); } // #636 — synthesize-a-description instruction. Many source assets (notably // scraped docs: `source`/`title`/`scraped`) carry frontmatter but NO // `description`. Reflect echoes the source frontmatter, so the proposal // inherits the missing description and the promote-time validator // (isValidDescription, 20–400 chars) rejects it. The fix is at GENERATION // time: when the source lacks a non-empty `description` and the type // requires one, tell the model — unmissably — that it MUST author a valid // `description`. (The validator/promote path is NOT changed: it must never // fabricate content to pass itself.) if (resolvedType && requiresDescription(resolvedType) && !sourceHasNonEmptyDescription(input.assetContent)) { sections.push([ "REQUIRED — synthesize a `description` (the source asset has none):", `- The source frontmatter does NOT include a non-empty \`description\`, but a ${resolvedType} asset REQUIRES one or the proposal will be rejected at promote time.`, `- You MUST author a valid \`description\` in the proposal frontmatter: ${DESCRIPTION_MIN_CHARS}–${DESCRIPTION_MAX_CHARS} characters of plain-prose sentence summarizing what this asset is about.`, '- Synthesize it from the asset\'s `title:` frontmatter, its first `# Heading`, or the opening body sentence. Do NOT copy a bare heading fragment (e.g. "Overview", "Named Page", "Key Insight") and do NOT emit a truncated phrase that ends on `:`/`;`/`,` or a hanging connector word.', ].join("\n")); } } if (input.assetContent?.trim()) { // Cap defaults to REFLECT_CONTENT_CAP (12 000 chars) to stay well under OS // ARG_MAX when the prompt is passed as a CLI argument to opencode/claude // — large assets (wiki snapshots, long runbooks) would otherwise trigger // E2BIG on posix_spawn. Direct-LLM callers never touch argv, so they may // pass a larger, context-aware `contentBudgetChars` (#952); it is never // allowed below the flat floor, which the caller enforces before calling in. const contentCap = input.contentBudgetChars ?? REFLECT_CONTENT_CAP; const body = input.assetContent.trimEnd(); const truncated = body.length > contentCap; sections.push(truncated ? `Current asset content (first ${contentCap} chars — full asset is ${body.length} chars):` : "Current asset content (verbatim):"); sections.push("```"); sections.push(truncated ? `${body.slice(0, contentCap)}\n${REFLECT_TRUNCATION_MARKER}` : body); sections.push("```"); } else if (input.ref) { sections.push("(No existing content.)"); } else { sections.push("(No existing asset content was supplied.)"); } if (input.schemaHints && input.schemaHints.length > 0) { sections.push("Schema / lint hints to address:"); for (const line of input.schemaHints) sections.push(`- ${line}`); } if (input.rejectedProposals && input.rejectedProposals.length > 0) { const lines = ["## Previously Rejected Proposals"]; lines.push("The following proposals for this ref were already reviewed and rejected. " + "Do not propose the same change again; if no other change is justified, return null for each field."); for (const rp of input.rejectedProposals) { lines.push(`\nRef: ${rp.ref}`); lines.push(`Rejection reason: ${rp.reason}`); if (rp.contentPreview) { lines.push("Rejected content preview:"); lines.push("```"); lines.push(rp.contentPreview); lines.push("```"); } } sections.push(lines.join("\n")); } if (input.avoidPatterns && input.avoidPatterns.length > 0) { sections.push(`## Avoid These Patterns\nRun-only guidance: do not copy this heading, explanation, or any diagnostic below into the proposed asset.\nPrevious assets in this run produced these errors — do not repeat them:\n${input.avoidPatterns.map((e) => `- ${e}`).join("\n")}`); } // R-1 / #372: Self-Refine (arXiv:2303.17651) — inject prior draft as critique target. // On refinement iterations (iter > 0), the agent is shown its previous proposal // and asked to self-critique and improve it rather than starting from scratch. if (input.priorDraft?.trim()) { sections.push("## Self-Refine: Critique and Improve\n" + "The following is your previous draft proposal. " + "Identify specific weaknesses: missing evidence, vague wording, incomplete frontmatter, " + "or claims that duplicate existing content without adding new signal. " + "Then produce an improved version that addresses those weaknesses.\n\n" + "Previous draft:\n```\n" + input.priorDraft.trimEnd() + "\n```"); } if (input.ref && input.assetContent?.trim()) { const problems = frontmatterProblems(input.assetContent); sections.push(problems.length > 0 ? `akm found these problems in the asset; fix each one:\n${problems.map((p) => `- ${p}`).join("\n")}` : "akm found no missing or broken field."); } sections.push(reflectResponseContract(input.outputMode ?? "json_schema", input.ref !== undefined)); return { prompt: sections.join("\n\n") }; } /** * Build the prompt for `akm proposal new <type> <name> --task ...`. Asks the * engine to author a brand-new asset of the given type fulfilling `task`, and * to return it as the JSON object of {@link RESPONSE_CONTRACT_JSON}. */ export function buildProposePrompt(input) { const sections = []; sections.push(`Author a new akm stash asset of type "${input.type}" named "${input.name}".`); sections.push(`Task: ${input.task}`); sections.push(`Asset-type guidance: ${hintForType(input.type)}`); sections.push(`(Known asset types: ${knownTypeList()}.)`); if (input.schemaHints && input.schemaHints.length > 0) { sections.push("Schema / lint hints:"); for (const line of input.schemaHints) sections.push(`- ${line}`); } if (input.standardsContext?.trim()) { sections.push("Standards to follow (the rulebook for this target):"); sections.push(input.standardsContext.trim()); } { const authoringRules = authoringRulesForType(input.type); if (authoringRules) { sections.push(authoringRules); } } sections.push("Produce a single proposal that, if accepted, would land as the asset described above."); sections.push(RESPONSE_CONTRACT_JSON); return sections.join("\n\n"); } /** * Parse agent stdout into a proposal payload. The agent contract requires a * single JSON object; anything else is reported as a parse error so callers * can map to {@link AgentFailureReason} `parse_error`. * * Resilient to two common local-LLM failure modes: * 1. `<think>…</think>` blocks emitted before the JSON (stripped before parsing). * 2. Prose preamble / postamble around the JSON object (handled by `extractEmbeddedJson`). */ export function parseAgentProposalPayload(stdout) { // Strip <think> blocks and fences, then attempt full parse with embedded fallback. const trimmed = stripCodeFences(stripThinkBlocks(stdout)).trim(); if (!trimmed) throw new Error("agent produced empty output"); let parsed; try { parsed = JSON.parse(trimmed); } catch (directErr) { // Agent output contains prose before/after the JSON object (e.g. a local // LLM that narrates before responding). Try extracting the first balanced // top-level `{…}` from the text rather than failing immediately. const embedded = parseEmbeddedJsonResponse(trimmed); if (!embedded) throw directErr; parsed = embedded; } const verdict = validateProposalPayload(parsed); if (!verdict.ok) throw new Error(verdict.errors.join("; ")); return verdict.value; } /** * The proposal in a parsed reply, or why the reply is not one: `ref` and * `content` must be non-empty strings. A malformed optional field is dropped, * never refused. */ export function validateProposalPayload(parsed) { if (!isRecord(parsed)) return { ok: false, errors: ["agent response is not a JSON object"] }; if (typeof parsed.ref !== "string" || !parsed.ref.trim()) { return { ok: false, errors: ['agent response missing required string field "ref"'] }; } if (typeof parsed.content !== "string" || !parsed.content.trim()) { return { ok: false, errors: ['agent response missing required string field "content"'] }; } const out = { ref: parsed.ref.trim(), content: parsed.content, }; if (isRecord(parsed.frontmatter)) out.frontmatter = parsed.frontmatter; // Phase 6A: extract optional `confidence` (number in [0, 1]). Clamp gently // rather than reject — a model that returns 1.0 or 0 with extra precision // (e.g. 1.0000001) should still surface a usable score. Anything that isn't // a finite number is dropped so downstream `createProposal` can rely on the // shape invariant. if (typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence)) { const clamped = Math.max(0, Math.min(1, parsed.confidence)); out.confidence = clamped; } return { ok: true, value: out }; }